前言
在上一章中我们使用了ID3算法来构造树。ID3的做法是每次选取当前最佳的特征来分割数据,并按照该特征的所有可能取值来切分。也就是说,如果一个特征有4种取值,那么数据将被切成4份。一旦按某特征切分后,该特征在之后的算法执行过程中将不会再起作用,所以有观点认为这种切分方式过于迅速。另外一种方法是二元切分法,即每次把数据集切成两份。如果数据的某特征值等于切分所要求的值,那么这些数据就进人树的左子树,反之则进人树的右子树。
除了切分过于迅速外,ID3算法还存在另一个问题,它不能直接处理连续型特征。只有事先将连续型特征转换成离散型,才能在ID3算法中使用。但这种转换过程会破坏连续型变量的内在性质。而使用二元切分法则易于对树构建过程进行调整以处理连续型特征。具体的处理方法是:如果特征值大于给定值就走左子树,否则就走右子树。另 外 ,二元切分法也节省了树的构建时间,但这点意义也不是特别大,因为这些树构建一般是离线完成,时间并非需要重点关注的因素。
本文继续讨论另一种二分决策树 Classification And Regression Tree,CART 是 Breiman 等人在 1984 年提出的,是一种应用广泛的决策树算法,不同于 ID3 与 C4.5, CART 为一种二分决策树, 每次对特征进行切分后只会产生两个子节点,而ID3 或 C4.5 中决策树的分支是根据选定特征的取值来的,切分特征有多少种不同取值,就有多少个子节点(连续特征进行离散化即可)。
回归树与模型树
在线性回归的模型中,我们可以看到,线性回归是一个关于全局数据的模型,用一个唯一的目标优化来优化全数据集。当数据呈现分段特性时,比如下图所示的数据,全局共享一个优化目标显然不是一个很好的选项。这个时候我们可以对数据集进行划分,分而治之明显是一个不错的选择。下图所示的数据可以划分为5个片段分别进行处理,分段之后可以对于每一个片段做一个线性模型,这种情况称之为模型树;也可以对分片之后的数据取均值,这种情况就是普通的回归树。接下来我们先从回归树的生成以及实现说起。
回归树采用均方误差作为损失函数,树生成时会递归的按最优特征与最优特征下的最优取值对空间进行划分,直到满足停止条件为止,停止条件可以人为设定,比如说设置某个节点的样本容量小于给定的阈值c,或者当切分后的损失减小值小于给定的阈值 ε,则停止切分,生成叶节点。
对于生成的回归树,每个节点的类别为落到该叶节点数据的标签的均值,假设特征空间被分为M个部分,即现在有M个叶节点分别为,对应的数据量分别为,则叶节点的预测值分别为:
回归树为一颗二叉树,每次都是按特征下的某个取值进行划分,每一个内部节点都是做一个对应特征的判断,直至走到叶节点得到其类别,构建这棵树的难点在于如何选取最优的切分特征与切分特征对应的切分变量。回归树与模型树既可以处理连续特征也可以处理离散特征,对于连续特征,若这里按第j个特征的取值s进行切分,切分后的两个区域分别为:
假如为离散特征,则找到第j个特征下的取值s:
根据(∗)分别计算与的类别估计与, 然后计算按切分后得到的损失:
找到使损失最小的对即可,递归执行的选择过程直到满足停止条件为止。这里以连续特征为例,给出回归树的算法:
输入:
输出:回归树T
第一步:求解选择切分特征j切分特征取值s,j将训练集D划分为两部分,与,依照切分后如下:
第二步:遍历所有可能的解,找到最优的,最优的解使得对应损失最小,按照最优特征来切分即可。
第三步:递归第一步和第二步
第四步:返回决策树T
回归树主要采用了分治策略,对于无法用唯一的全局线性回归来优化的目标进行分而治之,进而取得比较准确的结果,但分段取均值并不是一个明智的选择,可以考虑将叶节点设置为一个线性函数,这便是所谓的分段线性模型树。
本章将构建两种树:第一种是回归树, 其每个叶节点包含单个值;第二种是模型树,其每个叶节点包含一个线性方程。创建这两种树时,我们将尽量使得代码之间可以重用。下面先给出两种树构建算法中的一些共用代码。创建regTree.py文件并添加以下代码。
from numpy import *
import matplotlib.pyplot as plt
'''
函数说明:加载数据集
Parameters:
filename - 包含数据的文件
Returns:
dataMat - 数据集矩阵
Modify:
2018/10/26
'''
def loadDataSet(filename):
dataMat = [] #初始化返回矩阵
fr = open(filename) #打开数据文件
for line in fr.readlines():
curLine = line.strip().split('\t')
fltLine = list(map(float,curLine)) #将每行映射为浮点数
dataMat.append(fltLine)
return dataMat
'''
函数说明:将数据集按照某个特征值二分
Parameters:
dataSet - 数据集
feature - 要划分的特征
value - 该特征下的某一个特征值
Returns:
mat0,mat1 - 划分后的数据矩阵
Modify:
2018/10/26
'''
def binSplitDataSet(dataSet,feature,value):
mat0 = dataSet[nonzero(dataSet[:,feature] > value)[0],:]
mat1 = dataSet[nonzero(dataSet[:,feature] <= value)[0],:]
return mat0,mat1
'''
函数说明:构建树函数
Parameters:
dataSet - 数据集
leafType - 建立叶子结点的函数
errType - 误差计算函数
ops - 包含构建树所需其他参数的元组
Returns:
retTree - 输出树结构
Modify:
2018/10/26
'''
def createTree(dataSet,leafType=regLeaf,errType=regErr,ops=(1,4)):
#寻找最佳的二元切分方式
feat,val = chooseBestSplit(dataSet,leafType,errType,ops)
#满足停止条件返回叶子结点值
if feat == None:
return val
retTree = {}
retTree['spInd'] = feat
retTree['spVal'] = val
lSet,rSet = binSplitDataSet(dataSet,feat,val)
retTree['left'] = createTree(lSet,leafType,errType,ops)
retTree['right'] = createTree(rSet,leafType,errType,ops)
return retTree
if __name__ == '__main__':
testMat = mat(eye(4))
mat0,mat1 = binSplitDataSet(testMat,1,0.5)
print(mat0)
print(mat1)
上述程序中并未实现chooseBestSplit()函数,但我们可以测试一下binSplitDataSet的功能,得到的结果我们可以看到mat0和mat1按照样本比例1:3划分开来。下面将针对回归树构建,在 chooseBestSplit()函数里加入具体代码,之后便可以用CART算法构建回归树。
为了要成功构建以分段常数为叶节点的树,需要度量数据的一致性,之前的ID3算法中我们会在给定节点上计算数据的混乱度(熵),那么如何计算连续性数据的混乱度呢?我们呢可以采取计算总方差的方式度量连续性数据集的混乱度,即首先计算所有数据的均值,然后计算每条数据的值到均值的差值。为了对正负差值同等看待,一般使用绝对值或者平方值来代替上述差值,总方差可以通过均方差乘以数据集中样本点的个数来得到。
实现chooseBestSplit()函数只需完成两件事:用最佳的方式切分数据集和生成相应的叶节点。从上面的代码我们可以看到,chooseBestSplit()函数包含leafType,errType以及ops三个参数,leafType是对创建叶节点的函数的引用,errType是对前面介绍的总方差计算函数的引用,ops是用户自定义的一个元组,包含了之后CART算法会用得到的停止生成树的阈值。打开regTree文件添加以下的代码:
'''
函数说明:寻找数据的最佳二元切分方式
Parameters:
dataSet - 数据集
leafType - 均值计算函数
errType - 总方差计算函数
Returns:
bestIndex - 最佳划分特征索引值
bestValue - 最佳划分特征值
Modify:
2018/10/27
'''
def chooseBestSplit(dataSet,leafType=regLeaf,errType=regErr,ops=(1,4)):
#tols是容许的误差最小的下降值,tolN是切分的最少样本数
tolS = ops[0];tolN = ops[1]
#如果目标变量所有值相等,则退出
if len(set(dataSet[:,-1].T.tolist()[0])) == 1:
return None,leafType(dataSet)
#数据集的行列数
m,n = shape(dataSet)
#计算总方差
S = errType(dataSet)
#初始化最优误差,最优特征索引,最优特征值
bestS = inf; bestIndex = 0;bestValue = 0
for featIndex in range(n-1):
for splitVal in set(dataSet[:,featIndex].T.A.tolist()[0]):
mat0,mat1 = binSplitDataSet(dataSet,featIndex,splitVal)
#如果某个子集大小小于tolN,则本次循环不进行切分
if (shape(mat0)[0] < tolN) or (shape(mat1)[0] < tolN):
continue
#子集总误差
newS = errType(mat0) + errType(mat1)
#如果误差估计更小,则更新最优特征索引值和特征值
if newS < bestS:
bestIndex = featIndex
bestValue = splitVal
bestS = newS
#如果切分后的误差下降值很小,则退出
if (S - bestS) < tolS:
return None,leafType(dataSet)
return bestIndex,bestValue
if __name__ == '__main__':
myDat = loadDataSet('ex00.txt')
myMat = mat(myDat)
tree = createTree(myMat)
print(tree)
运行程序后可以打印出树的结构,我们一看一下数据的可视化效果,添加以下代码,加入regTree.py文件中。
"""
函数说明:绘制数据集
Parameters:
filename - 数据集文件
Returns:
无
Modify:
2018/10/29
"""
def plotDataSet(filename):
dataMat = loadDataSet(filename) #加载数据集
n = len(dataMat) #数据个数
x = []; y = [] #样本点
for i in range(n):
x.append(dataMat[i][1]); y.append(dataMat[i][2]) #样本点
fig = plt.figure()
ax = fig.add_subplot(111) #添加subplot
ax.scatter(x, y, s = 15, c = 'red',alpha = .9) #绘制样本点
plt.title('DataSet Visual') #绘制title
plt.xlabel('X')
plt.ylabel('Y')
plt.show()
再看一个多次切分的例子,如下所示:
可以检査一下该树的结构以确保树中包含5个叶节点。我们也可以在更复杂的数据集上构建回归树并观察实验结果。
到现在为止,已经完成回归树的构建,但是需要某种措施来检查构建过程否得当。下面将介绍树剪枝技术 ,它通过对决策树剪枝来达到更好的预测效果。
树剪枝
决策树是充分考虑了所有的数据点而生成的复杂树,有可能出现过拟合的情况,决策树越复杂,过拟合的程度会越高。(理论来说,不应该是拟合程度越高,预测结果越准确嘛?为什么还要避免这种情况?)
考虑极端的情况,如果我们令所有的叶子节点都只含有一个数据点,那么我们能够保证所有的训练数据都能准确分类,但是很有可能得到高的预测误差,原因是将训练数据中所有的噪声数据都”准确划分”了,强化了噪声数据的作用。(形成决策树的目的作出合理的预测,尽可能有效的排除噪声数据干扰,影响正确预测的判断)。
剪枝修剪分裂前后分类误差相差不大的子树,能够降低决策树的复杂度,降低过拟合出现的概率。(换句话说就是把重负累赘的子树用一个根节点进行替换,也就是说根节点的数据意义完全可以代替由该节点衍生出的子树的所有节点的意义)。
如何进行剪枝?两种方案-——预剪枝和后剪枝
预剪枝:通过提前停止树的构建而对树剪枝,一旦停止,节点就是树叶,该树叶持有子集元祖最频繁的类。
停止决策树生长最简单的方法有:
- 定义一个高度,当决策树达到该高度时就停止决策树的生长
- 达到某个节点的实例具有相同的特征向量,及时这些实例不属于同一类,也可以停止决策树的生长。这个方法对于处理数据的数据冲突问题比较有效
- 定义一个阈值,当达到某个节点的实例个数小于阈值时就可以停止决策树的生长
- 定义一个阈值,通过计算每次扩张对系统性能的增益,并比较增益值与该阈值大小来决定是否停止决策树的生长。
其实本章前面已经进行过预剪枝处理。在函数chooseBestSplit( )中的提前终止条件,实际上是在进行一种所谓的预剪枝操作 。
后剪枝:它首先构造完整的决策树,允许树过度拟合训练数据,然后对那些置信度不够的结点子树用叶子结点来代替,该叶子的类标号用该结点子树中最频繁的类标记。相比于先剪枝,这种方法更常用,正是因为在先剪枝方法中精确地估计何时停止树增长很困难。
下面我们将采用最基础的一种剪枝算法,REP—错误削减剪枝
错误消减剪枝是对“基于成本复杂度的剪枝”的一种优化,但是仍然需要一个单独的测试数据集,不同的是在于这种方法可以直接使用完全诱导树对测试集中的实例进行分类,对于诱导树中的非叶子节树,该策略是用一个叶子节点去代替该子树,判断是否有益,如果剪枝前后,其错误率下降或者是不变,并且被剪掉的子树不包含具有相同性质的其他子树,那么就用这个叶子节点代替这个叶子树,这个过程将一直持续进行,直至错误率出现上升的现象。
简单点说:该剪枝方法是根据错误率进行剪枝,如果一棵子树修剪前后错误率没有下降,就可以认为该子树是可以修剪的。REP剪枝需要用新的数据集,原因是如果用旧的数据集,不可能出现分裂后的错误率比分裂前错误率要高的情况。由于使用新的数据集没有参与决策树的构建,能够降低训练数据的影响,降低过拟合的程度,提高预测的准确率。
下面就在regTree.py文件中创建剪枝函数prune()函数,其伪代码如下:
- 基于已有的树切分测试数据
- 如果存在任意子集是一棵树,则该子集递归剪枝
- 计算将当前两个节点合并后的误差
- 计算不合并的误差
- 如果合并会降低误差的话,就将节点合并
为了解实际效果,添加如下代码:
'''
函数说明:判断输入变量是否是一颗树
Parameters:
obj - 测试对象
Returns:
True or False
Modify:
2018/10/29
'''
def isTree(obj):
import types
return (type(obj).__name__ =='dict')
'''
函数说明:对树进行塌陷处理
Parameters:
tree - 树
Return:
树的平均值
Modify:
2018/10/29
'''
def getMean(tree):
if isTree(tree['right']):
tree['right'] = getMean(tree['right'])
if isTree(tree['left']):
tree['left'] = getMean(tree['left'])
return(tree['left'] + tree['right']) / 2.0
'''
函数说明:后剪枝处理
Parameters:
tree - 树
test - 测试集
Rerurns:
后剪枝树
Modify:
2018/10/29
'''
def prune(tree,testData):
#如果测试集为空,对树进行塌陷处理
if shape(testData)[0] == 0:
return getMean(tree)
#如果有左子树或者右子树,则切分数据集
if (isTree(tree['right']) or isTree(tree['left'])):
lSet,rSet = binSplitDataSet(testData,tree['spInd'],tree['spVal'])
#处理左子树
if isTree(tree['left']):
tree['left'] = prune(tree['left'],lSet)
#处理右子树
if isTree(tree['right']):
tree['right'] = prune(tree['right'],rSet)
#如果当前结点的左右节点为叶节点的话
if not isTree(tree['left']) and not isTree(tree['right']):
lSet,rSet = binSplitDataSet(testData,tree['spInd'],tree['spVal'])
#计算没有合并的误差
print(lSet[:,-1])
print(tree['left'])
errorNoMerge = sum(power(lSet[:,-1] - tree['left'],2)) + sum(power(rSet[:,-1] - tree['right'],2))
#计算合并的均值
treeMean = (tree['left'] + tree['right']) / 2.0
#计算合并的误差
errorMerge = sum(power(testData[:,-1] - treeMean,2))
#如果合并的误差小于没有合并的误差,那么就合并
if errorMerge < errorNoMerge:
return treeMean
else:
return tree
else:
return tree
if __name__ == '__main__':
myDat = loadDataSet('ex2.txt')
myMat = mat(myDat)
tree = createTree(myMat)
print(tree)
test_filename = 'ex2test.txt'
test_Data = loadDataSet(test_filename)
test_Mat = mat(test_Data)
pruneResult = prune(tree, test_Mat)
print(pruneResult)
上述我们采用的是REP方法后剪枝,也是最简单和最常用的方法,也存在一些缺点,但除此之外还有PEP,EBP以及和随机森林一起的更好的方法,这里就不再详述了。
下面我们将重用部分代码创建另一种新的树,该树采用二元切分,但是叶子节点不再是简单的数值,取而代之的是一些线性模型。
模型树
用树来对数据建模,除了把叶节点简单地设定为常数值之外,还有一种方法是把叶节点设定为分段线性函数,这里所谓的分段线性是指模型由多个线性片段组成。如果读者仍不清楚,下面很快就会给出样例来帮助理解。 考虑到下图中的数据,如果使用两条直线拟合是否比使用一组常数来建模好呢? 答案显而易见。可以设计两条分别从0.0~0.3、从0.3~1.0的直线,于是就可以得到两个线性模型。因为数据集里的一部分数据(0.0~0.3)以某个线性模型建模,而另一部分数据(0.3~1.0)则以另一个线性模型建模,因此我们说采用了所谓的分段线性模型。
决策树相比于其他机器学习算法的优势之一在于结果更易理解。很显然,两条直线比很多节点组成一棵大树更容易解释。模型树的可解释性是它优于回归树的特点之一。另外,模型树也具有更髙的预测准确度 。
前面的代码稍加修改就可以在叶节点生成线性模型而不是常数值。下面将利用树生成算法对数据进行切分,且每份切分数据都能很容易被线性模型所表示。该算法的关键在于误差的计算, 需要给出每次切分时用于误差计算的代码。
那么为了找到最佳切分,应该怎样计算误差呢?前面用于回归树的误差计算方法这里不能再用。稍加变化,对于给定的数据集,应该先用线性的模型来对它进行拟合,然后计算真实的目标值与模型预测值间的差值。最后将这些差值的平方求和就得到了所需的误差。为了解实际效果,打开regTree.py文件并加人如下代码:
'''
函数说明:数据集格式化
Parameters:
dataSet - 数据集
Returns:
目标变量X,Y,回归系数ws
Modify:
2018/10/29
'''
def linearSolve(dataSet):
m,n = shape(dataSet)
X = mat(ones((m,n)))
Y = mat(ones((m,1)))
X[:,1:n] = dataSet[:,0:n-1];Y = dataSet[:,-1]
xTx = X.T * X
if linalg.det(xTx) == 0.0:
raise NameError('矩阵的逆不存在,不能被转置')
ws = xTx.I * (X.T * Y)
return ws,X,Y
'''
函数说明:生成叶子结点
Parameters:
dataSet - 数据集
Returns:
回归系数ws
Modify:
2018/10/29
'''
def modelLeaf(dataSet):
ws,X,Y = linearSolve(dataSet)
return ws
'''
函数说明:计算误差
Parameters:
dataSet - 数据集
Returns:
误差
Modify:
2018/10/29
'''
def modelErr(dataSet):
ws,X,Y = linearSolve(dataSet)
yHat = X * ws
return sum(power(Y - yHat,2))
if __name__ == '__main__':
myDat = loadDataSet('exp2.txt')
myMat = mat(myDat)
tree = createTree(myMat,modelLeaf,modelErr,(1,10))
print(tree)
运行程序后可以看到,该代码以0.285477为界限创建了两个模型,实际数据应该在0.3处,
函数createTree()生成的这两个线性模型分别是y=3.468+1.1852x和y=0.0016985+11.96477x,与用于生成该数据的真实模型非常接近。 该数据实际是由模型y=3.5+1.0x和y=12x再加上高斯噪声生成的。
下图可以看到数据点以及生成的线性模型:
小结
数据集中经常包含一些复杂的相互关系,使得输人数据和目标变量之间呈现非线性关系。对这些复杂的关系建模,一种可行的方式是使用树来对预测值分段,包括分段常数或分段直线。一般采用树结构来对这种数据建模。相应地,若叶节点使用的模型是分段常数则称为回归树,若叶节点使用的模型是线性回归方程则称为模型树。
CART算法可以用于构建二元树并处理离散型或連续型数据的切分。若使用不同的误差准则就可以通过CART算法构建模型树和回归树。该算法构建出的树会倾向于对数据过拟合。一棵过拟合的树常常十分复杂,剪枝技术的出现就是为了解决这个问题。两种剪枝方法分别是预剪枝(在树的构建过程中就进行剪枝)和后剪枝(当树构建完毕再进行剪枝),预剪枝更有效但需要用户定义一些参数。
PS
该部分内容所设计到的程序源码已经存在我的github上,地址奉上:
https://github.com/AdventureSJ/ML-Notes/tree/master/CART
欢迎各位大佬批评指正,也欢迎各位好友fock or star!
Thank You!