目录
一. 什么是决策树
决策树是一个基于树形结构的分类模型,它将数据集中的不同特征的不同取值对数据集进行分割,构造出一个树形结构,这个树形结构就是决策树,使用决策树能够进行分类任务,预测数据样本的分类。
之前实验我们使用的分类算法是k近邻算法,它是一个很容易实现的分类模型,可以完成很多的分类任务,但它最大的缺点就是无法给出数据的内在含义,因为它只是通过计算样本点的距离来进行分类的,这个距离并不包含对该样本分类的实际意义,而决策树的每一个分类都有对应一组特征判断的条件,可以很直观的看出分类规则,其数据形式非常容易理解。
接下来我们来看一个简单的决策树实例
根据一只动物的特征来判别该动物是不是老虎
该动物是哺乳动物吗?
该动物是食肉动物吗?
该动物是黄褐色的吗?
该动物有黑色条纹吗?
决策树的工作原理就如上图通过问多个问题,根据这些问题的回答来给出最终的结果。
这些问题就是特征,每次选择一个特征(问题),根据这个特征的不同取值创建不同的子结点,这些子结点再如父节点一样继续选择其他特征创建子结点。也可以看作是根据特征对数据进行划分,在这个特征上取值相同的数据样本同在一个样本集合中,根据该特征的不同取值划分出多个样本集合,这些样本集合再各自选择其他特征划分数据。
构造完成的决策树由结点和有向边组成,结点包括两种类型:内部结点和叶子结点,内部结点表示一个特征或属性,叶子结点表示一个类。
二. 构造决策树
理解决策树的基本概念后,我们接着来思考决策树该怎么创建。
对于上面这个决策树,我们首先选择以 ”对旅游的渴望程度“ 这个特征进行划分,当其取值为 ”非常高“ 时,其划分的样本中分类结果有两种,去和不去,比例为3:1,当其取值为 ”高“ 时,其样本中分类结果同样有两种,比例为2:2。
而如果选择以收入特征进行划分,则当其取值为 ”高“ 时,其划分的样本中分类结果只有一种:去,当其取值为 ”低“ 时,其样本中分类结果同样有两种,去和不去,比例为1:3。
对于上面第一层选择不同的特征对数据划分得到的分类结果,显然是选择以 ”收入“ 进行划分得到的结果更好(这里只考虑第一层的划分结果),因为当其取值 ”高“ 时,样本集合中只有一个类别,没有不确定性,取值 ”低“ 时,两种类别的占比差距大,有很大的把握将样本分类为其中一个类别,分类的不确定性较低,而使用 ”对旅游的渴望程度“ 这个特征进行分类,虽然其中一个取值的不确定性较低,但其另一个取值两种分类的占比相同,这很难决定该将这个样本分为哪个类别,分类的不确定性很高。
因此,我们能得知划分数据集的大原则是:将无序的数据变得更加有序。
也就是我们在划分数据集时就要注意使样本集合经过划分后,每个划分出的子样本集合中的所有样本尽可能的属于同一个类别。从树的角度说,就是使当前结点生成子结点后,每个子结点所包含的样本尽量属同一个类别。
我们可以使用结点的 “纯度” 来表示该结点的有序程度,该结点 “纯度” 越高,则该结点所包含的样本越趋向于同属一个类别。那么这个划分原则就可以理解为使划分出的子样本集合的纯度越来越高。
如何实现这个要求呢?我们通常使用的划分方法有:信息增益,增益率,基尼指数。
1. 划分数据集的方法
1.1. 信息增益
信息增益是指数据集划分前后信息发生的变化,也可以理解为划分数据集后的 “纯度提升”。
信息增益越大,则划分后所带来的纯度提升越大,因此我们首先计算每个特征划分数据集获得的信息增益,再选择获得信息增益最高的特征进行划分。
那么信息增益该怎么计算呢?信息增益是数据集划分前后信息发生的变化,因此在计算信息增益前,我们首先要知道这里的信息该如何来度量,香农熵就是集合信息的度量方式。
1.1.1. 熵的计算
香农熵,简称熵,是度量样本集合纯度最常用的一种指标
熵这个字本身的含义是体系混乱程度的度量,在信息论中,熵是用来衡量信息的不确定性和混乱程度,与熵的物理含义是类似的。
熵的计算公式是
或
上面公式中对数的底是2
为什么是这个公式呢?因为熵是通过计算信源的平均编码长度来度量信息量的,出现概率越大的信源比特数(bit位)越少,出现概率越小的信源比特数越多。
比如在一个文本中,有90%以上的字都是“中”,那么这个文本的重复内容就很多,相对的它包含的信息量就很少,那么这个文本的平均信息量就很少。
我们根据每个字的概率赋予它相应的码长,概率越大,码长越短,概率越小,码长越长,再将每个字的概率与它的码长相乘再求和,计算出每个字的平均编码长度,使用这个平均码长乘以文本总字数得到的就是用来衡量这个文本的最小的信息量,这个最小的信息量值就能很好的描述信息包含的信息量,这里的每个字的平均码长就是每个信源所包含的平均信息量,这个平均信息量显然也可以用来衡量文本的信息量。
回到样本集合中,假设一个样本集合中的样本共有两种类别,两种类别占比(0.9,0.1)和(0.5,0.5)这两种情况哪种的纯度更高呢?显然是第一种(0.9,0.1)。考虑在文本中,假设这个文本只包含两个字,一种情况是两个字占比分别为(0.9,0.1),一种是(0.5,0.5),显然是第二种包含的信息量多,因为第一种文本中有大量的重复。在样本集合中同样也是如此,若样本集合包含的信息量越多,则说明该样本集合越混乱,相应纯度就越低,因此我们就可以使用熵来衡量样本集合的纯度,样本集合的熵越大,则样本越混乱,不确定性越高,纯度越低,样本集合的熵越小,则纯度越高。因此样本集合中占比大的样本的占比越大,占比小的样本占比越小,样本的熵越小,样本越有序,纯度越高。
上次实验我们已经掌握了使用matplotlib.pyplot包来绘图,现在我们绘图二分类样本集合中观察熵与正样本概率p的关系来验证这个结论。
import numpy as np
import matplotlib.pyplot as plt
# 从0.01和0.99中选取1000个连续的x值,不能取0和1,因为计算时会出现计算log(0)的情况,得到nan
x = np.linspace(0.01, 0.99, 100)
# 计算y值,因为x是一个集合,y同样是一个集合
y = -(x * np.log2(x) + (1 - x) * np.log2(1 - x))
# 加入两个数据点(0,0)和(1,0),注意第一个点在最前面,
# 第二个点在最后面,顺序不能错,否则绘出的图形会出错
x = np.insert(np.append(x, 1), 0, 0)
y = np.insert(np.append(y, 0), 0, 0)
plt.plot(x, y)
plt.xlim([0, 1])
plt.ylim([0, 1.05])
plt.xlabel('p')
plt.ylabel('entropy')
plt.show()
通过上图我们可以看出,在p越靠近中心,熵越大,达到1,在两侧时最小,值为0,结果与上面的结论相同。
上面我们已经了解了熵的基本概念和计算公式,现在我们来开始计算熵。
首先对上面的决策树计算熵
选择 “对旅游的渴望程度” 特征进行划分:
首先计算各个子样本集合中各个样本类别的占比
在左子树中,包含两个类别,“去” 与 “不去”,占比分别为0.75和0.25
在右子树中,同样包含两个类别,“去” 与 “不去” ,占比分别为0.5和0.5
接着就可以计算熵了
左子树的熵为:
右子树的熵为:
计算结果:
可以看出,左子树熵比右子树低,实际也是左子树比右子树更有序。
下面我们按照上面的步骤编写代码计算给定数据集的熵
def cal_entropy(data_set):
entropy = 0
# 统计各标签个数
labelCounts = {}
for sample in data_set:
# 划分是根据特征取值划分的,计算熵是根据标签计算的,
# 使用决策树也不需要单独对特征进行矩阵计算,
# 因此,为了方便划分,不将特征和标签分开,标签值为每个样本的最后一列
if sample[-1] not in labelCounts.keys():
labelCounts[sample[-1]] = 1
else:
labelCounts[sample[-1]] += 1
# 根据各标签类别的占比计算熵
for value in labelCounts.values():
ratio = value / len(data_set)
entropy += -(ratio * np.log2(ratio))
return entropy
我们使用上面例子测试函数是否输出正确结果
数据集:
为了方便,使用0和1代替各个特征的取值,第一个特征取值1,0分别代表非常高和高,第二个特征取值1,0分别代表高和低,第三个特征取值1,0分别代表高和低。
对旅游的渴望程度,旅游费用,收入,决定
1,1,1,1
1,1,0,0
1,0,1,1
1,0,0,1
0,1,1,1
0,1,0,0
0,0,1,1
0,0,0,0
# 左子树划分为第一个特征(对旅游渴望程度)取值为1(非常高)的所有样本
lchild = data_set[data_set[:, 0] == 1]
# 右子树划分为第一个特征(对旅游渴望程度)取值为0(高)的所有样本
rchild = data_set[data_set[:, 0] == 0]
entropy = cal_entropy(data_set)
entropy1 = cal_entropy(lchild)
entropy2 = cal_entropy(rchild)
print('H =', entropy)
print('H1 =', entropy1)
print('H2 =', entropy2)
输出结果:
计算结果与上面我们手算的结果相同,函数计算正确。
1.1.2. 信息增益的计算
知道熵的计算方法后,我们就可以接着来计算信息增益了
假设数据集的一个特征为a,a有V个可能的取值{a1,a2,a3,....,aV},使用a来划分数据集,能得到v个子结点,对每个子结点分别计算其熵值,再将各个结点的熵根据该结点的所包含的样本数占当前样本集合的样本数的比例加权求平均值。
计算公式如下:
Ent(D)是划分前数据集的熵,Ent(Dv)是在特征a上取值为av的分枝结点的熵,Dv是在特征a上取值为av的样本个数,D是总样本个数。
还是使用上面的例子,我们来计算使用 “对旅游的渴望程度” 这个特征来划分所获得的信息增益
上面我们已经计算出了该特征的两个取值的分枝结点的熵和划分前数据集的熵,现在使用它们来计算信息增益。
计算结果
我们再来计算一下使用 “收入” 这个特征进行划分所能获得的信息增益
可以看出使用 “收入” 特征进行划分所获得的信息增益是很大的,远大于“对旅游的渴望程度”,选择 ”收入“ 进行划分显然比选择 “对旅游的渴望程度” 好得多。
信息增益能够很好的选择最优的划分选择,但它有一个缺点,那就是对可取值数目较多的特征有偏好。比如如果把 “编号” 也当成一个特征,每个样本的编号都不相同,因此每个取值的样本集合都只有一个样本,该样本的占比为1,此时每个子样本集合的熵都是:
则信息增益达到:
Gain(D, a) = Ent(D) - 0 = Ent(D)
此时能达到最大的信息增益。
但显然使用编号进行划分的决策树是没有泛化能力的,对没有见过的数据的预测能力很差。
1.2. 增益率
为了解决上面的问题,我们可以使用增益率来替换信息增益对数据集进行划分。
增益率计算的公式如下:
上式中,称为特征a的“固有值”,其计算方式类似熵,同熵一样,特征a的取值数量越多,则IV(a)的值通常就越大。
“收入” 和 “对旅游的渴望程度” 的子样本集合都只有两个,每个子样本集合的数量占比都是1/2,因此它们的IV都是1,得到的增益率与信息增益相同。
而 “编号” 的子样本集合有8个,每个子样本集合的占比都是1/8,则它的IV是3,则它的增益率是0.318144667641655。
根据这个公式,我们可以编写计算增益率的代码:
# data_set:数据集,feature_index:要计算增益率的特征的下标
def cal_gain_ratio(data_set, feature_index):
# 计算每个子结点的熵,选取数据集中第feature_index个特征的所有取值,
# 分别计算各个取值的样本集合的熵
# cal_entropy( data_set[data_set[:, feature_index] == value]的作用是:
# 计算数据集中第feature_index个特征取值为value的样本集合的熵,
# value从数据集中第feature_index个特征的取值集合中取(不重复)
# 使用tensor存储是为了方便熵集合与标签比例集合相乘
entropys = torch.tensor([ cal_entropy( data_set[data_set[:, feature_index] == value] )
for value in set(data_set[:, feature_index]) ] )
# 计算每个标签的比例(各个子结点的样本数占比),
# sum(1 for sample in data_set if sample[feature_index] == value)的作用是:
# 计算第feature_index个特征取值为value的样本数,
# value同样从数据集中第feature_index个特征的取值集合中取(不重复)
label_ratio = torch.tensor([sum(1 for sample in data_set if sample[feature_index] == value) /
len(data_set) for value in set(data_set[:, feature_index]) ])
# 计算平均熵
avg_entropy = sum( label_ratio * entropys )
# 计算信息增益
gain = (cal_entropy(data_set) - avg_entropy)
# 计算信息增益率
gain_ratio = gain / (sum(label_ratio * np.log2(label_ratio) * -1))
return gain_ratio.item()
计算使用编号特征划分数据集或获得的增益率
这个结果与我们手算的结果相同。
相比信息增益的0.954434002924965无疑减少了很多,它也不再是划分数据集的最佳选择。因此使用增益率可以有效地解决信息增益偏好多取值特征的缺点。
1.3. 基尼指数
在了解基尼指数前,我们首先要了解基尼值。
基尼值也是衡量数据集纯度的一个指标,它的定义是:
在分类问题中,假设D有K个类别,样本属于第k个类别的概率为pk,则概率分布的基尼值为:
或
Gini值越小,数据集的纯度越高。
基尼指数的定义:
划分时,选择使基尼指数最小的属性作为划分属性。
本次实验实验使用增益率来选择最佳划分方法,因此这里不过多介绍基尼指数。
2. 划分数据集
了解了划分数据集的多种方法后,接下来我们开始选择最佳的划分方式并对数据集进行划分
首先要做的是选择最好的数据集划分方式,这里我们使用的方式是增益率。
基本的步骤如下:
1. 遍历数据集,依次选择数据集中的特征,对每个特征执行如下操作:
1.1. 计算使用该特征进行划分所获得的增益率
1.2. 如果该增益率比最高的增益率大,则执行如下操作:
1.2.1. 将该增益率设为最大增益率
· 1.2.2. 将该特征设为最佳划分特征
2. 返回最佳划分特征
根据上面的步骤,给出相应的代码:
def choose_best_feature(data_set):
max_gain_ratio = 0
best_feature = 0
# 遍历数据集的每一个特征(下标),注意特征数量是列数-1,因为最后一列是标签
for i in range(len(data_set[0,:]) - 1):
# 计算该特征的增益率
gain_ratio = cal_gain_ratio(data_set, i)
# 判断该增益率是否大于最大增益率,是则更新最大增益率和最佳特征
if gain_ratio > max_gain_ratio:
max_gain_ratio = gain_ratio
best_feature = i
# 返回最佳划分特征
return best_feature
第一次选择输出结果:
接着我们再根据选择的最佳划分特征来对数据集进行划分
3. 创建决策树
在创建决策树前我们先要处理在创建决策树过程中会出现的几种情况。
第一种情况:样本集合中的样本全部属于同一个类别C,或者说样本集合中只有一种标签值C。此时没有再划分下去的必要了,因为已经可以完全确定样本集合中的所有样本属于C类别了。将当前结点标记为C类叶结点,不再继续划分。
第二种情况:没有可选的特征,即划分到最大深度,此时已经没有特征可以选择。将当前结点标记为叶结点,将其类别标记为当前样本中最多的类别(此时的样本集合不一定只有一个样本,因为可能存在特征取值相同,当标签不同的样本)
第三种情况:样本集合中的样本在所有特征上取值都相同,此时没有办法再划分下去,因为样本集合中的样本在当前剩余的所有特征的取值上都相同,无法根据在特征上的不同取值来划分数据集,直接将当前结点标记为叶子结点,类别标记为当前样本中最多的类别。
除了这三种特殊情况,还有一种在创建分枝结点会出现的情况,即分枝结点包含的样本集合为空,也就是当前样本集合中没有在当前划分特征上取值为当前遍历值的样本,这种情况也叫做样本缺失,此时我们可以将这个结点直接标记为叶子结点,将其类别标记为当前样本集合中最多的类别。
对于其他正常情况,创建树的步骤如下:
1. 从当前剩余特征集合中选取最佳的特征;
2. 遍历该特征的每一个值value,执行如下操作:
2.1. 为当前结点创建一个新的分枝结点,该结点包含的样本集合为在该特征取值为value的所有样本;
2.2. 如果新结点包含的样本集合为空,则将该结点标记为叶结点,类别标记为当前样本集合中最多的类别,当前创建结束;
2.3. 不满足2.2条件则将当前的划分特征从特征集合中删除,继续选择剩余的特征创建树
对应代码:
# feature_name:特征的标签集合(如收入,旅游费用),feat_val_labels:特征取值标签(如高、低),最后一个特征默认为类别
def create_tree(data_set, feature_labels, feat_val_labels):
# 样本集合中的样本全都属于同一个类别
# 计算样本集合中与第一个样本的类别相同的样本个数,
# 如果该个数与样本集合的总样本个数相同则所有所有样本都属于同一个类别
class_list = [classx[-1] for classx in data_set]
if class_list.count(data_set[0][-1]) == len(data_set):
# 直接返回样本集合中任意一个类别(都相同),从字典中取该类别的标签(去、不去)
# feature_labels[-1]是取类别的标签,默认类别在末尾
try:
cla = feat_val_labels[feature_labels[-1]][int(data_set[0][-1])]
except IndexError:
print('特征取值标签数组越界,数据集包含特征取值标签数组中不包含的数据,',
'特征标签取值不足或数据集出错或特征取值标签数组不包含该特征')
return cla
# 没有可选特征,所有特征均已被遍历
if len(data_set[0]) == 1:
# 返回当前样本集合中最多的类别,Counter计算每个类别出现的次数,
# most_common(1)[0][0]获取counter中出现次数最高(可选择个数,1则是只有一个,即最高的元素)
# 的元素和出现次数,我们只需要出现次数最高的元素即可
try:
cla = feat_val_labels[feature_labels[-1]][int(Counter(data_set[:, -1]).most_common(1)[0][0])]
except IndexError:
print('特征取值标签数组越界,数据集包含特征取值标签数组中不包含的数据,',
'特征标签取值不足或数据集出错或特征取值标签数组不包含该特征')
return cla
best_feat = choose_best_feature(data_set)
try:
feat_label = feature_labels[best_feat]
except IndexError:
print('特征标签数值越界,特征标签个数与特征数不匹配')
# 创建该结点,结点是一个字典,键是特征的标签,值是子结点集合
# (子结点集合是字典,键是该特征的取值,值是该取值的样本集合)
decision_tree = {feat_label: {}}
del(feature_labels[best_feat])
for value in set(data_set[:, best_feat]):
new_data_set = data_set[data_set[:, best_feat] == value]
# 使用该值划分出的数据集为空,无法划分,将该结点标记为叶子结点,类别标记为当前数据集最多的类别
if len(new_data_set) == 0:
try:
cla = feat_val_labels[feature_labels[-1]][int(Counter(data_set[:, -1]).most_common(1)[0][0])]
except IndexError:
print('特征取值标签数组越界,数据集包含特征取值标签数组中不包含的数据,',
'特征标签取值不足或数据集出错或特征取值标签数组不包含该特征')
return cla
# 以当前取值继续向下创建结点,样本集合需要删去已经使用过的最佳特征
# decision_tree[feat_label]是当前结点的子结点集合,子结点集合使用字典存储,
# 每个键值对包含特征取值以及对应的子结点
# [feat_val_labels[feat_label][int(value)]]是子结点集合中对应取值的结点的键(取值的标签(如高、低))
try:
decision_tree[feat_label][feat_val_labels[feat_label][int(value)]] = \
create_tree(np.delete(new_data_set, best_feat, axis=1),
feature_labels[:], feat_val_labels)
except IndexError:
print('特征取值标签数组越界,数据集包含特征取值标签数组中不包含的数据,',
'特征标签取值不足或数据集出错或特征取值标签数组不包含该特征')
return decision_tree
使用上面的例子测试:
输出结果:
这里决策树存储的是用每个特征和取值的实际意义,而非数值,便于后面对决策树解释,需要给出特征和分类的标签字典来将对应的值转换为标签。
上面输出的是以字典形式存储的决策树,根结点为一个特征,对应键为特征名,值为以该特征划分的样本集合,划分出的样本集合同样用字典存储,键是树的边,即划分特征的取值,值是对应取值的样本集合,每个子结点同根结点一样包含多个子样本集合。
三、绘制注解树
上面我们已经训练出一个决策树了,但这样的一个决策树我们不好直观地观察,我们可以使用Matplotlib工具将这个决策树的树形绘制出来。
绘制的基本原理是在坐标图中以折现图作为树的边,以Matplotlib的注解工具annotations添加的文本注解作为树的结点,再在两个结点之间添加注释文本作为边的值。
要绘制决策树,我们首先要知道树的宽度和深度,以确定整个树所要占的大小范围,将根结点置于最上层中心位置,接着从根结点向左右绘制子结点,子结点再继续向下绘制。
获取树的宽度和深度的方法是类似的,获取宽度即获取树的叶子结点数,遍历根结点的子结点,计算子结点叶子结点数也即根结点的叶子结点数,子结点再继续遍历它的子结点,计算它的子结点的叶子结点数,重复操作,直到遍历到叶子结点,直接返回1,使用递归可以轻易实现。获取树的深度也是一样的操作,只不过深度与叶子结点数计算方式不一样而已,深度是取最大的深度,从根结点到下一步步累加深度,取一个结点的子结点的深度中的最大深度。
代码如下:
def count_leafs(tree):
num_leafs = 0
# 叶子结点
if not isinstance(tree, dict):
return 1
childtree = next(iter(tree.values()))
for value in childtree.values():
# 遍历该结点,计算该结点的叶子结点数
num_leafs += count_leafs(value)
return num_leafs
def count_tree_depth(tree):
max_depth = 0
# 是叶子结点则直接返回1
if not isinstance(tree, dict):
return 1
childtree = next(iter(tree.values()))
for value in childtree.values():
# 遍历该子结点,计算该子结点的深度
temp_depth = 1 + count_tree_depth(value)
# 更新最大深度
max_depth = max(max_depth, temp_depth)
return max_depth
绘制决策树:
decision_node = dict(boxstyle='sawtooth', fc='0.8')
leaf_node = dict(boxstyle="round4", fc='0.8')
arrow_args = dict(arrowstyle="<-")
def plot_node(node_txt, cntr_pt, parent_pt, node_type):
create_plot.ax1.annotate(node_txt, xy=parent_pt, xycoords='axes fraction',
xytext = cntr_pt, textcoords = 'axes fraction', va = "center", ha = "center", \
bbox = node_type, arrowprops = arrow_args, fontproperties='SimHei')
def plot_mid_text(cntr_pt, parent_pt, txt_str):
x_mid = (parent_pt[0] - cntr_pt[0]) / 2 + cntr_pt[0]
y_mid = (parent_pt[1] - cntr_pt[1]) / 2 + cntr_pt[1]
create_plot.ax1.text(x_mid - 0.04, y_mid, txt_str, rotation=25, fontproperties='SimHei')
# create_plot()
def plot_tree(tree, parent_pt, node_txt):
numleafs = count_leafs(tree)
depth = count_tree_depth(tree=tree)
first_str = next(iter(tree))
cntr_pt = (plot_tree.xOff + (1.0 + float(numleafs)) / 2.0 / plot_tree.totalW, plot_tree.yOff)
plot_mid_text(cntr_pt, parent_pt, node_txt)
plot_node(first_str, cntr_pt, parent_pt, decision_node)
second_dict = tree[first_str]
plot_tree.yOff = plot_tree.yOff - 1.0 / plot_tree.totalD
for key in second_dict.keys():
if isinstance(second_dict[key], dict):
plot_tree(second_dict[key], cntr_pt, str(key))
else:
plot_tree.xOff = plot_tree.xOff + 1.0 / plot_tree.totalW
plot_node(second_dict[key], (plot_tree.xOff, plot_tree.yOff), cntr_pt, leaf_node)
plot_mid_text((plot_tree.xOff, plot_tree.yOff), cntr_pt, str(key))
plot_tree.yOff = plot_tree.yOff + 1.0 / plot_tree.totalD
def create_plot(intree):
fig = plt.figure(1, facecolor='white')
fig.clf()
axprops = dict(xticks=[], yticks=[])
create_plot.ax1 = plt.subplot(111, frameon=False, **axprops)
plot_tree.totalW = float(count_leafs(intree))
plot_tree.totalD = float(count_tree_depth(intree))
plot_tree.xOff = -0.5 / plot_tree.totalW; plot_tree.yOff = 1.0
plot_tree(intree, (0.5, 1.0), '')
plt.show()
调用create_plot()函数,传入决策树参数,即可完成绘制。
绘制结果:
四、剪枝
上面我们已经创建完了决策树,已经可以使用它来进行预测了,但是直接创建没有任何限制的决策树还有一点缺陷。
当决策树的决策分枝过多时,模型会很复杂,容易将训练集自身的一些特点当做所有数据都具有的一般性质,导致在遇到没见过的数据集时预测结果会较差,也就是发生“过拟合”。
要解决这个问题,我们就要尝试简化决策树,去掉一些不必要的分枝,来提高决策树的泛化能力。
这个操作就叫做 “剪枝”。
剪枝的基本策略有“前剪枝”和“后剪枝”
1. 预剪枝
预剪枝是在划分过程中对树进行剪枝,其基本原理是预先给出一些限制条件,当树达到这个条件时就停止树的生长,或者在划分时先估计当前划分能否为决策树的泛化能力带来提升,如果不能则停止树的生长,以达到剪枝的效果。
预剪枝主要方法有:
1. 当决策树 达到预设的高度 时就停止决策树的生长2. 达到某个节点的实例集 具有相同的特征向量(属性取值相同) ,即使这些实例不属于同一类,也可以停止决策树的生长。3. 定义一个阈值,当达到某个节点的 实例个数小于阈值 时就可以停止决策树的生长。4. 通过 计算每次扩张对系统性能的增益 ,决定是否停止决策树的生长。
上述方法的第二种就是创建决策树时我们所讨论的几种情况的第三种,样本集合中可能存在特征取值相同,但类别不同的样本,这时为了提高决策树的泛化能力,通常情况我们会将该结点直接标记为叶子结点,停止决策树的生长。
上述方法的第三种需要我们不断跳转阈值,来寻找一个欠拟合和过拟合的均衡点,达到最佳的效果。
第四种方法的具体步骤是:在划分前计算直接将该节点标记为叶结点对验证集预测的精度以及划分后对验证集验证的精度,根据二者的精度来判断是否需要划分。
上面的步骤要用到验证集,因此我们需要将数据集进行拆分,一部分作为训练集,一部分作为测试集。
下面我们换一个复杂一点的数据集来尝试预剪枝。
这里使用的数据集是隐形眼镜数据集,可以前往UCI机器学习库网站下载。
下载后,lenses.data是数据集,lenses.name是数据集信息。
或者直接复制下面的数据(第一列为编号)
1 1 1 1 1 3
2 1 1 1 2 2
3 1 1 2 1 3
4 1 1 2 2 1
5 1 2 1 1 3
6 1 2 1 2 2
7 1 2 2 1 3
8 1 2 2 2 1
9 2 1 1 1 3
10 2 1 1 2 2
11 2 1 2 1 3
12 2 1 2 2 1
13 2 2 1 1 3
14 2 2 1 2 2
15 2 2 2 1 3
16 2 2 2 2 3
17 3 1 1 1 3
18 3 1 1 2 3
19 3 1 2 1 3
20 3 1 2 2 1
21 3 2 1 1 3
22 3 2 1 2 2
23 3 2 2 1 3
24 3 2 2 2 3
属性信息:
7. Attribute Information:
-- 3 Classes
1 : the patient should be fitted with hard contact lenses,
2 : the patient should be fitted with soft contact lenses,
3 : the patient should not be fitted with contact lenses.1. age of the patient: (1) young, (2) pre-presbyopic, (3) presbyopic
2. spectacle prescription: (1) myope, (2) hypermetrope
3. astigmatic: (1) no, (2) yes
4. tear production rate: (1) reduced, (2) normal
我们先不剪枝,训练上述的数据集得到一个决策树并绘制其树形形状。
这个数据集的属性信息较长,因此绘制出来的图形不尽人意,不过勉强也够用了。
接下来我们需要将数据集划分成训练集和测试集。
此数据集较小,为了保证足够的测试集数量,可以适当提高测试集的比例,这里我选取了9个样本作为测试集,为了更好的评价训练集对每个类别的预测精度,因此我从数据集中抽取了分类为1、2、3的样本各3个,共9个。
测试集:
训练集:
使用训练集进行训练
训练结果:
根据上面的决策树,我们首先进行划分的是tear特征,现在我们进行预剪枝,在划分前先判断划分是否提高了决策树的预测性能。
划分前决策树完全没有划分,对测试集的预测精度是类别的占比,即33.34%。
如果进行划分,划分结果如下:
红色框中是取值为2的样本,非红框则是取值为1的样本,这样的决策树会将取值为2的样本全部标记为2,取值为1的全部标记为3,此时再对测试集预测,得到的结果是四个样本预测错误,五个预测正确,得到的预测精度是55.56%,精度得到提升,可以划分。
预剪枝是在划分时进行的,因此预剪枝可以使决策树在划分到一定情况时就停止划分,能够显著减少训练时间和测试时间开销,同时也能减小决策树的大小,节省存储决策树的内存花销,还能降低过拟合的风险。但预剪枝也有导致决策树欠拟合的风险,因为预剪枝只对当前的划分进行评估是否要划分,但会存在一些划分虽不能提高泛化性能,但在其基础上的下一次划分却能够显著提升决策树的性能,然而使用预剪枝就会直接禁止这个划分,导致决策树可能出现欠拟合的情况。
2. 后剪枝
与预剪枝在划分时进行剪枝不同,后剪枝是在决策树生成后,自底向上地对非叶结点进行分析计算将该结点替换为叶结点能否为决策树带来泛化性能的提升,如果能则将该结点替换为叶结点。
这里还是使用上面的决策树,上面是划分好后的决策树,此时的决策树的验证集预测精度为55.56%。
测试集:
首先对prescript属性做计算,若将该属性替换为叶结点,该结点直接被标记为 ”no lenses“ 类别,得到的结果是44.45%,精度下降了,因此不对该结点进行剪枝,再考虑age结点,进行剪枝后为66.67%,因此对该结点进行剪枝,继续向上重复操作,直到根结点。
后剪枝因为是在决策树生成后进行的,因此后剪枝并不能减少决策树训练的时间,相比预剪枝会消耗更多的训练时间。但正是因为后剪枝是生成后进行的,因此可以避免上面所说的预剪枝的缺点,欠拟合的风险较小,泛化性能通常要比预剪枝好。
要评价进行剪枝后决策树的性能是否提升,则要将测试集输入决策树进行预测,判断预测结果的正确率,下面是预测分类的代码:
# testVec是一个字典,包含每个输入特征对应特征的取值
def classify(tree, feat_val_labels, test_vec):
class_label = ''
if not isinstance(tree, dict):
return tree
# 当前字典第一个键的名称(特征)
first_node = next(iter(tree))
# feat_index = feat_labels.index(first_node)
for key in tree[first_node].keys():
if test_vec[first_node] == key:
class_label = classify(tree[first_node][key], feat_val_labels, test_vec)
return class_label
决策树是使用上面的训练集训练出来的,如下图:
预测一个测试集中的样本,看看是否正确
预测样本:
test_vac = {'age': 'young', 'prescript': 'myope', 'astigmatic': 'yes', 'tear': 'normal'}
对应测试集中的分类标签为1(hard),接着来看看决策树输出的结果:
预测正确!
在预剪枝和后剪枝中,判断剪枝是否为模型是带来性能提升就是调用这个函数预测测试集中每个样本的分类,将与实际的分类相比,得到正确分类的结果,将正确预测的数量除以测试集总样本数就能得到精度,再根据这个精度判断是否需要剪枝。