当数据拥有众多特征并且特征之间关系十分复杂时,构建全局模型的想法就显得太难了,也略显笨拙。而且,实际生活中很多问题都是非线性的,不可能使用全局线性模型来拟合任何数据。
一种可行的方法是将数据集切分成很多份易建模的数据,然后利用第八章的线性回归技术来建模。如果首次切分后仍然难以拟合线性模型就继续切分。在这种切分方式下,树结构和回归法就相当有用。
9.1复杂数据的局部性建模
第三章使用决策树进行分类。决策树不断将数据切分成小数据集,直到所有目标变量完全相同,或者数据不能再切分为止。决策树是一种贪心算法,它要在给定的时间内做出最佳选择,但不关心能否达到全局最优。
第三章中使用的树构建算法是 ID3 。ID3 的做法是每次选取当前最佳的特征来分割数据,并按照该特征的所有可能取值来切分。也就是说,如果一个特征有 4 种取值,那么数据将被切分成 4 份。一旦按照某特征切分后,该特征在之后的算法执行过程中将不会再起作用,所以有观点认为这种切分方式过于迅速。除了切分过于迅速外, ID3 算法还存在另一个问题,那就是它不能直接处理连续型特征。只有事先将连续型特征转换成离散型,才能在 ID3 算法中使用。但这种转换过程会破坏连续型变量的内在性质。
另外一种树构建方法是二分切分法,即每次把数据集切分成两份。如果数据的某特征值等于切分所要求的值,那么这些数据就进入树的左子树,反之则进入树的右子树。使用二元切分法则易于对树构造过程进行调整以处理连续型特征。具体的处理方法是: 如果特征值大于给定值就走左子树,否则就走右子树。另外,二分切分法也节省了树的构建时间,但这点意义也不是特别大,因为这些树构建一般是离线完成,时间并非需要重点关注的因素。
CART 是十分著名且广泛记载的树构建算法,它使用二元切分来处理连续型变量,对其稍作修改就可处理回归问题。
树回归的优缺点:
优点:可以对复杂和非线性的数据建模。
缺点:结果不易理解。
适用数据类型:数值型和标称型数据。
树回归的一般方法:
1、收集数据:采用任意方法收集数据。
2、准备数据:需要数值型的数据,标称型数据应该映射成二值型数据。
3、分析数据:绘出数据的二维可视化现实结果,以字典方式生成树。
4、训练算法:大部分时间都花费在结叶点树模型的构建上。
5、测试算法:使用测试数据上的R2值来分析模型的效果。
6、使用算法:使用训练出的树做预测,预测结果还可以用来做很多事情。
9.2连续和离散型特征的树的构建
在树的构建过程中,需要解决多种类型数据的存储问题。与第三章类似,这里将使用一部字典来存储树的数据结构,该字典将包含以下四个元素:
1、待切分的特征。
2、待切分的特征值。
3、右子树。当不再需要切分的时候,也可以是单个值。
4、左子树。与右子树类似。
函数createTree()的伪代码大致如下:
找到最佳的待切分特征:
如果该节点不能再分,将该节点存为叶节点
执行二元切分
在右子树调用createTree()方法
在左子树调用createTree()方法
创建文件regTrees.py并添加下列代码:
import numpy as np
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
# 将数据集分成两个
def binSplitDataSet(dataSet, feature, value):
# np.nonzero(a),返回数组a中非零元素的索引值数组
# np.nonzero(dataSet[:, feature] > value)[0]=1,
# 下面一行代码表示mat0=dataSet[1,:]即第一行所有列
mat0 = dataSet[np.nonzero(dataSet[:, feature] > value)[0], :]
mat1 = dataSet[np.nonzero(dataSet[:, feature] <= value)[0], :]
return mat0, mat1
测试代码:
import regTrees
from numpy import *
testMat = mat(eye(4))
mat0, mat1 = regTrees.binSplitDataSet(testMat, 1, 0.5)
print('testMat:\n',testMat)
print('mat0:\n',mat0)
print('mat1:\n',mat1)
结果:
分析:
可以看到,通过程序我们创建了一个简单的单位矩阵,并利用np.nonzero()函数按指定的值来切分了该矩阵。
9.3将CART算法用于回归
为成功构建以分段常数为叶节点的树,需要度量出数据的一致性。第三章使用树进行分类,会在给定节点时计算数据的混乱度。首先计算所有数据的均值,然后计算每条数据的值到均值的差值。为了对正负差值同等看待,一般使用绝对值或平方值来代替上述差值。
找数据集最佳切分点:
对每个特征:
对每个特征值:
将数据集切分成两份(小于该特征值的数据样本放在左子树,否则放在右子树)
计算切分的误差
如果当前误差小于当前最小误差,那么将当前切分设定为最佳切分并更新最小误差
返回最佳切分的特征和阈值
构建树
基于CART算法构建回归树的简单数据集:
测试回归树的分段常数数据集:
在regTrees.py中添加下列代码:
# 负责生成叶节点,当chooseBestSplit()函数确定不再对数据进行切分时,
# 将调用该regLeaf()函数来得到叶节点的模型,在回归树中,该模型其实就是目标变量的均值
def regLeaf(dataSet) :
return np.mean(dataSet[:, -1])
# 误差估计函数,该函数在给定的数据上计算目标变量的平方误差,这里直接调用均方差函数var
# 因为这里需要返回的是总方差,所以要用均方差乘以数据集中样本的个数
def regErr(dataSet) :
return np.var(dataSet[:, -1]) * np.shape(dataSet)[0]
# dataSet: 数据集合
# leafType: 给出建立叶节点的函数
# errType: 误差计算函数
# ops: 包含树构建所需其他参数的元组
def createTree(dataSet, leafType=regLeaf, errType=regErr, ops=(1,4)) :
# 将数据集分成两个部分,若满足停止条件,chooseBestSplit将返回None和某类模型的值
# 若构建的是回归树,该模型是个常数。若是模型树,其模型是一个线性方程。
# 若不满足停止条件,chooseBestSplit()将创建一个新的Python字典,并将数据集分成两份,
# 在这两份数据集上将分别继续递归调用createTree()函数
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
# 回归树的切分函数,构建回归树的核心函数。目的:找出数据的最佳二元切分方式。如果找不到
# 一个“好”的二元切分,该函数返回None并同时调用createTree()方法来产生叶节点,叶节点的
# 值也将返回None。
# 如果找到一个“好”的切分方式,则返回特征编号和切分特征值。
# 最佳切分就是使得切分后能达到最低误差的切分。
def chooseBestSplit(dataSet, leafType=regLeaf, errType=regErr, ops=(1,4)) :
# tolS是容许的误差下降值
# tolN是切分的最小样本数
tolS = ops[0]; tolN = ops[1]
# 如果剩余特征值的数目为1,那么就不再切分而返回
if len(set(dataSet[:, -1].T.tolist()[0])) == 1 :
return None, leafType(dataSet)
# 当前数据集的大小
m,n = np.shape(dataSet)
# 当前数据集的误差
S = errType(dataSet)
bestS = np.inf; bestIndex = 0; bestValue = 0
for featIndex in range(n-1) :
for splitVal in set(dataSet[:, featIndex].tolist()[0]):
mat0, mat1 = binSplitDataSet(dataSet, featIndex, splitVal)
if (np.shape(mat0)[0] < tolN) or (np.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)
mat0, mat1 = binSplitDataSet(dataSet, bestIndex, bestValue)
# 检查切分后的子集大小,如果某个子集的大小小于用户定义的参数tolN,那么也不应切分。
if (np.shape(mat0)[0] < tolN) or (np.shape(mat1)[0] < tolN) :
return None, leafType(dataSet)
# 如果前面的这些终止条件都不满足,那么就返回切分特征和特征值。
return bestIndex, bestValue
测试代码:
import regTrees
from numpy import *
# 运行二元切分代码
myData = regTrees.loadDataSet('ex00.txt')
myMat = mat(myData)
print(regTrees.createTree(myMat))
# 多次切分的列子
myData1 = regTrees.loadDataSet('ex0.txt')
myMat1 = mat(myData1)
print(regTrees.createTree(myMat1))
结果:
9.4树剪枝
一棵树如果节点过多,表明该模型可能对数据进行了“过拟合”。通过降低决策树的复杂度来避免过拟合的过程称为剪枝。在函数chooseBestSplit()中的提前终止条件,实际上是在进行一种所谓的预剪枝操作。另一种形式的剪枝需要使用测试集和训练集,称作后剪枝。
9.4.1预剪枝
预剪枝就是及早的停止树增长,在构造决策树的同时进行剪枝。所有决策树的构建方法,都是在无法进一步降低熵的情况下才会停止创建分支的过程,为了避免过拟合,可以设定一个阈值,熵减小的数量小于这个阈值,即使还可以继续降低熵,也停止继续创建分支。但是这种方法实际中的效果并不好。
9.4.2后剪枝
使用后剪枝方法需要将数据集分成测试集和训练集。首先指定参数,使得构建出的树足够大、足够复杂,便于剪枝。接下来从上而下找到叶节点,用测试集来判断这些叶节点合并是否能降低测试误差。如果是的话就合并。
函数prune()的伪代码如下:
基于已有的树切分测试数据:
如果存在任一子集是一棵树,则在该子集递归剪枝过程
计算将当前两个叶节点合并后的误差
计算不合并的误差
如果合并会降低误差的话,就将叶节点合并
在regTrees.py中添加下列代码:
# 判断节点是否是一个字典
def isTree(obj):
"""
Desc:
测试输入变量是否是一棵树,即是否是一个字典
Args:
obj -- 输入变量
Returns:
返回布尔类型的结果。如果 obj 是一个字典,返回true,否则返回 false
"""
return (type(obj).__name__ == 'dict')
# 计算左右枝丫的均值
def getMean(tree):
"""
Desc:
从上往下遍历树直到叶节点为止,如果找到两个叶节点则计算它们的平均值。
对 tree 进行塌陷处理,即返回树平均值。
Args:
tree -- 输入的树
Returns:
返回 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
# 检查是否适合合并分枝
def prune(tree, testData):
"""
Desc:
从上而下找到叶节点,用测试数据集来判断将这些叶节点合并是否能降低测试误差
Args:
tree -- 待剪枝的树
testData -- 剪枝所需要的测试数据 testData
Returns:
tree -- 剪枝完成的树
"""
# 判断是否测试数据集没有数据,如果没有,就直接返回tree本身的均值
if np.shape(testData)[0] == 0:
return getMean(tree)
# 判断分枝是否是dict字典,如果是就将测试数据集进行切分
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)
# 上面的一系列操作本质上就是将测试数据集按照训练完成的树拆分好,对应的值放到对应的节点
# 如果左右两边同时都不是dict字典,也就是左右两边都是叶节点,而不是子树了,那么分割测试数据集。
# 1. 如果正确
# * 那么计算一下总方差 和 该结果集的本身不分枝的总方差比较
# * 如果 合并的总方差 < 不合并的总方差,那么就进行合并
# 注意返回的结果: 如果可以合并,原来的dict就变为了 数值
if not isTree(tree['left']) and not isTree(tree['right']):
lSet, rSet = binSplitDataSet(testData, tree['spInd'], tree['spVal'])
# power(x, y)表示x的y次方
errorNoMerge = sum(np.power(lSet[:, -1] - tree['left'], 2)) + sum(np.power(rSet[:, -1] - tree['right'], 2))
treeMean = (tree['left'] + tree['right'])/2.0
errorMerge = sum(np.power(testData[:, -1] - treeMean, 2))
# 如果 合并的总方差 < 不合并的总方差,那么就进行合并
if errorMerge < errorNoMerge:
print("merging")
return treeMean
else:
return tree
else:
return tree
测试代码:
import regTrees
from numpy import *
myDat = regTrees.loadDataSet('ex2.txt')
myMat2 = mat(myDat)
mytree=regTrees.createTree(myMat2,ops=(0,1))
myDatTest = regTrees.loadDataSet('ex2test.txt')
myMat2Test = mat(myDatTest)
print(regTrees.prune(mytree,myMat2Test))
结果:
分析:
程序出来的结果和书本上以及网上有的结果都不一样。本节的代码也没有看明白,不知道学了些什么东西。有时间还得回过头来重新修炼。o(╥﹏╥)o
9.5模型树
因为树回归每个节点是一些特征和特征值,选取的原则是根据特征方差最小。用树来对数据建模,除了把叶节点设定为常数值之外,还可以把叶节点设置为分段线性函数,这就是模型树。如下图可用2条直线模型来拟合。
在regTrees.py中添加下列代码:
def linearSolve(dataSet): # 将数据集分成自变量x集和目标变量y
m, n = np.shape(dataSet)
X = np.mat(np.ones((m, n)));
Y = np.mat(np.ones((m, 1)))
X[:, 1:n] = dataSet[:, 0:n - 1];
Y = dataSet[:, -1]
xTx = X.T * X
if np.linalg.det(xTx) == 0.0:
raise NameError('This matrix is singular, cannot do inverse,\n\
try increasing the second value of ops')
ws = xTx.I * (X.T * Y)
return ws, X, Y
def modelLeaf(dataSet): # 生成叶节点模型
ws, X, Y = linearSolve(dataSet)
return ws
def modelErr(dataSet): # 生成实际值与估计值之间的总误差
ws, X, Y = linearSolve(dataSet)
yHat = X * ws
return np.sum(np.power(Y - yHat, 2))
测试代码:
import regTrees
from numpy import *
myDat = regTrees.loadDataSet('exp2.txt')
myMat2 = mat(myDat)
print(regTrees.createTree(myMat2, regTrees.modelLeaf, regTrees.modelErr, (1, 10)))
结果:
分析:
结果本应该与真实模型十分接近,以某个树为界创建2个模型。但是我的结果与真实模型不符合,不知道哪里出了问题。
9.6小结
本章学的很迷糊,似懂非懂。后面的树剪枝和模型树代码跑出来的结果也与书本上的不一样,找不到哪里出了问题,头疼。