一、CART算法用于回归
1、原理
分类与回归树(CART)是应用广泛的决策树学习方法。CART由特征选择、树的生成及剪枝组成,既可以用于分类也可以用于回归。
算法如下:
输入:训练集
X
X
X
过程:
①寻找最优切分属性
j
j
j及其属性对应的切分值
s
s
s,求解:
min
j
,
s
[
∑
x
i
∈
R
1
(
j
,
s
)
(
y
i
−
c
1
)
2
+
∑
x
i
∈
R
2
(
j
,
s
)
(
y
i
−
c
2
)
]
\min_{j,s}[\sum_{x_i∈R_1(j,s)}(y_i-c_1)^2+\sum_{x_i∈R_2(j,s)}(y_i-c_2)]
j,smin[xi∈R1(j,s)∑(yi−c1)2+xi∈R2(j,s)∑(yi−c2)]
这里的
R
1
R_1
R1和
R
2
R_2
R2是依据最优切分属性
j
j
j和切分值
s
s
s得到的两个切分后区域。
c
1
c_1
c1和
c
2
c_2
c2为
R
1
R_1
R1和
R
2
R_2
R2中所有样本点标记的平均值。这里我们需要遍历所有属性及其属性对应的属性值。
②用选定的最优切分属性
j
j
j和最优切分值
s
s
s划分区域并以区域中相应样本的平均值作为输出值:
R
1
(
j
,
s
)
=
{
x
∣
x
(
j
)
≤
s
}
,
R
2
(
j
,
s
)
=
{
x
∣
x
(
j
)
≥
s
}
R_1(j,s)={x|x^{(j)}≤s},R_2(j,s)={x|x^{(j)}≥s}
R1(j,s)={x∣x(j)≤s},R2(j,s)={x∣x(j)≥s}
c
m
′
=
1
N
m
∑
x
i
∈
R
m
(
j
,
s
)
y
i
,
x
∈
R
m
,
m
=
1
,
2
c_m^{'}=\frac{1}{N_m}\sum_{x_i∈R_m(j,s)}y_i,x∈R_m,m=1,2
cm′=Nm1xi∈Rm(j,s)∑yi,x∈Rm,m=1,2
③继续对两个子区域调用步骤①、②,直至满足停止条件
④将输入空间划分为M个区域
R
1
,
R
2
,
.
.
.
,
R
M
R_1,R_2,...,R_M
R1,R2,...,RM,生成决策树:
f
(
x
)
=
∑
m
=
1
M
c
m
′
I
(
x
∈
R
m
)
f(x)=\sum_{m=1}^Mc_m^{'}I(x∈R_m)
f(x)=m=1∑Mcm′I(x∈Rm)
这里我们所说的停止条件主要包括:①对于当前叶节点,如果再次切分的误差与不切分的误差相差不大,则可以停止切分;②对于当前叶节点,如果含有训练样本数量已经够少了,那么停止切分。
下面我们谈谈它与决策树的异同点:
①决策树只能用于分类问题,而CART树即可以用于回归问题又可以用于分类问题;
②在切分时,决策树的一个属性只能用一次,而CART树的一个属性可以多次用于切分;
③切分属性及属性取值的选择准则不同:决策树选择信息增益最大的特征及取值;对于CART树,分类问题选择基尼系数最小的特征及取值,回归问题选择使用是平方误差最小的特征及取值;
④CART数只能是一颗二叉树,而决策树根据属性的取值个数而定。
而对于,剪枝我们采用和决策树剪枝相同的方法:如果切分出的两个叶节点的平方误差并没有比两个叶节点合并的平方误差小,则将两个叶节点剪枝,合并到他们的父节点。
2、代码部分
首先,我们编写一个用于数据集切分的程序,我们输入数据集X
,用于切分的属性axis
以及切分属性的取值value
:
import numpy as np
def splitData(X,axis,value):
leftX=X[np.nonzero(X[:,axis] <= value)[0]]
rightX=X[np.nonzero(X[:,axis] > value)[0]]
return leftX,rightX
这里leftX
为切分属性axis
小于等于切分值value
的数据集,right
为切分属性axis
大于切分值value
的数据集。
接着,我们编写对于一个数据集X
的平方误差的函数sqrError
和计算样本集X
中所有样本的标记的平均:
def sqrError(X):
sqrX = (X[:,-1] - X[:,-1].mean())**2
error = sqrX.sum()
return error
def Average(X):
average = X[:,-1].mean()
return average
下面为回归树的主函数:
def creatTree(X,errorType=sqrError,leafType=Average,ops=(1,4)):
RegTree={}
BestFeature,BestValue = chooseBestFeature(X,errorType,leafType,ops)
if BestFeature == None:
return BestValue
leftX,rightX = splitData(X,BestFeature,BestValue)
RegTree['splitFeature']=BestFeature
RegTree['splitValue'] = BestValue
RegTree['left'] = creatTree(leftX,errorType,leafType,ops)
RegTree['right'] = creatTree(rightX,errorType,leafType,ops)
return RegTree
这里输入X
代表训练集;errorType
表示我们训练过程中计算误差的方式,这里我们默认为sqrError
,即平方误差;leafType
代表我们计算叶节点取值的方式,这里默认参数为Average
,即平方误差;ops
代表需要输入的额外参数,下面我们会解释。
这里我们用字典去储存数的数据。我们用函数chooseBestFeature
得到最佳的划分属性BestFeature
以及最佳的划分值BestValue
。有了最佳划分属性BestFeature
以及最佳划分值BestValue
,我们就可以得到切分后的两个切分后的数据集leftX
、rightX
。然后我们用字典记录下最佳切分属性BestFeature
和最佳切分值BestValue
。由于当前最佳切分属性和最佳切分值对应的左子树和右子树存储切分的数据集leftX
和rightX
。因为当前节点对应的左子树和右子树依然是一棵树,所以我们可以递归的调用creatTree
去分别处理leftX
和rightX
。递归结束条件为BestFeature == None
,这里表示无需再切分,下面我们会在函数在chooseBestFeature
解释。
下面为函数chooseBestFeature
,即选择最佳属性的函数:
def chooseBestFeature(X,errorType,leafType,ops=(1,4)):
deltaS=ops[0]
minM=ops[1]
m,d = X.shape
d = d-1
S = sqrError(X)
bestError=100000
if len(set(list(X[:,-1])))==1:
return None,X[0,-1]
for i in range(d):
for splitValue in X[:,i]:
leftX,rightX = splitData(X,i,splitValue)
if (leftX.shape[0]<minM) or (rightX.shape[0]<minM):
continue
error=sqrError(leftX)+sqrError(rightX)
if error<bestError:
bestError=error
bestFeature = i
bestValue=splitValue
if (S-bestError)<deltaS:
return None,leafType(X)
leftX,rightX = splitData(X,bestFeature,bestValue)
return bestFeature,bestValue
这里的X
为训练集;errorType
表示计算误差的方法,这里使用的是平方误差;leafType
表示我们对叶节点计算的方式,这里使用对叶节点包含的所有样本求平均;这里ops
存放了两个参数,第一个参数deltaS
表示如果我们从一个节点切分后的误差比不切分的误差小于这个参数deltaS
,我们停止切分;第二个参数minM
表示如果一个节点包含的样本数小于这个参数,那么我们停止切分。
这里我们遍历所有的特征和此属性对应的所有取值,找到误差最小的属性和取值。如果在遍历过程中发现此属性和取值分隔的样本集中小于我们设定的值minM
,那么跳过这个这个属性和取值,寻找下一对属性和取值。遍历结束后,得到切分后的误差和不分割的误差,如果相差不大,则最佳属性bestFeature
返回None
,同时BestFeature == None
也是我们主函数creatTree
的递归结束条件,这时主程序也会停止递归;否则,正常返回最佳切分属性和取值bestFeature
和bestValue
。
这里需要说明的是,ops
中的两个参数的选定对于模型的泛化能力极为重要。如果deltaS
过小,说明过于追求样本标记和预测值的拟合程度,会发生过拟合;如果minM
过小,则该树容易切分过于细致,则也会发生过拟合。
下面为剪枝函数:
def decide_Tree(node):
return ((type(node).__name__) == dict)
def get_mean(tree):
if decide_Tree(tree['left']):
tree['left'] = get_mean(tree['left'])
if decide_Tree(tree['right']):
tree['tight'] = get_mean(tree['right'])
return ( tree['left']+tree['right'] ) / 2
def prune(tree,Xtest):
if Xtest.shape[0]==0:
return get_mean(tree)
if decide_Tree(tree['left']) or decide_Tree(tree['right']):
splitFeat = tree['splitFeature']
splitValue = tree['splitValue']
leftXtest,rightXtest = splitData(Xtest,splitFeat,splitValue)
if decide_Tree(tree['left']):
tree['left'] = prune(tree['left'],leftXtest)
if decide_Tree(tree['right']):
tree['right'] = prune(tree['right'],rightXtest)
if (not decide_Tree(tree['left'])) and (not decide_Tree(tree['right'])):
splitFeat = tree['splitFeature']
splitValue = tree['splitValue']
leftXtest,rightXtest = splitData(Xtest,splitFeat,splitValue)
leftError = np.sum((leftXtest[:,-1] - tree['left'])**2)
rightError = np.sum((rightXtest[:,-1] - tree['left'])**2)
mergeNoError = leftError + rightError
meanValue = (tree['left'] + tree['right']) / 2
mergeError = np.sum((Xtest[:,-1] - meanValue)**2)
if mergeNoError <= mergeError:
return meanValue
else:
return tree
else:
return tree
首先,decide_Tree
函数判断一个节点是否为数结构,如果是返回True
。函数get_mean
返回给节点的平均值,这里同样用了递归,如果输入的树的左右两个子节点为树,那么递归的对左右两个子节点继续求平均,直至左右两个子节点为叶节点时,对两个叶节点求平均。
如果当前节点测试时测试集只有一个样本,那么将当前节点对应的所有训练样本的平均作为该节点标记;第二个if
语句用于判断当前测试节点的左右两个节点是否为树,如果是树,那么将依据该节点的最佳切分点和最佳切分值,对测试集进行划分;如果二个if
语句执行,那么必执行第三、四个if
语句,或两个都执行,或两个执行一个,这两个if
主要用于判断该节点左右两个节点哪个节点为树,如果是树,那么进入子节点,继续进行剪枝;最后一个if
,用于处理节点为单层树的情况,即该节点的子节点为两个叶节点,如果该节点为单层树,则计算该单层树两叶节点中样本的误差mergeError
以及两叶节点剪枝后的误差mergeNoError
。如果mergeNoError
小于等于mergeError
,说明划分效果在测试集中并没有那么好,那么我们将该节点对应的单层树直接改成叶节点,该节点对应的值为原来左右叶节点对应的值的平均值;否则,不做改变,返回原树。这里需要注意的是其实剪枝后的节点对应的值我们用左右叶节点对应的值的平均值来计算,但是这样计算并不完全准确,因为在训练过程中无法保证两叶节点中对应的样本数量相同,但是这样做却可以让树的整体误差下降。
这里同样需要说明:这个剪枝算法为贪心算法,因为剪枝为从下而上的,它只能考虑一些树的情况,并不能考虑所有情况。
下面我们检查一下这个算法的运行情况:
先检查数据情况:
print(open('F:/MachineLearning/data/ex0.txt').read())
得到:
1.000000 0.409175 1.883180
1.000000 0.182603 0.063908
1.000000 0.663687 3.042257
1.000000 0.517395 2.305004
1.000000 0.013643 -0.067698
1.000000 0.469643 1.662809
1.000000 0.725426 3.275749
1.000000 0.394350 1.118077
1.000000 0.507760 2.095059
1.000000 0.237395 1.181912
1.000000 0.057534 0.221663
1.000000 0.369820 0.938453
1.000000 0.976819 4.149409
1.000000 0.616051 3.105444
1.000000 0.413700 1.896278
1.000000 0.105279 -0.121345
1.000000 0.670273 3.161652
1.000000 0.952758 4.135358
1.000000 0.272316 0.859063
1.000000 0.303697 1.170272
1.000000 0.486698 1.687960
1.000000 0.511810 1.979745
1.000000 0.195865 0.068690
1.000000 0.986769 4.052137
1.000000 0.785623 3.156316
1.000000 0.797583 2.950630
1.000000 0.081306 0.068935
1.000000 0.659753 2.854020
1.000000 0.375270 0.999743
1.000000 0.819136 4.048082
1.000000 0.142432 0.230923
1.000000 0.215112 0.816693
1.000000 0.041270 0.130713
1.000000 0.044136 -0.537706
1.000000 0.131337 -0.339109
1.000000 0.463444 2.124538
1.000000 0.671905 2.708292
...
下面为数据处理函数:
def prepared_data(path):
file = open(path)
data_list=file.readlines()
dataSet=[]
for data in data_list:
data = data.strip()
data = data.split()
data = [float(elem) for elem in data]
dataSet.append(data)
return np.array(dataSet)
数据处理,并查看,由于数据第一列全为1,所以可以去除:
data1 = prepared_data('F:/MachineLearning/data/ex0.txt')
data1 = np.delete(data1,0,axis=1)
print(data1)
得到:
[[ 4.091750e-01 1.883180e+00]
[ 1.826030e-01 6.390800e-02]
[ 6.636870e-01 3.042257e+00]
[ 5.173950e-01 2.305004e+00]
[ 1.364300e-02 -6.769800e-02]
[ 4.696430e-01 1.662809e+00]
[ 7.254260e-01 3.275749e+00]
[ 3.943500e-01 1.118077e+00]
[ 5.077600e-01 2.095059e+00]
[ 2.373950e-01 1.181912e+00]
[ 5.753400e-02 2.216630e-01]
[ 3.698200e-01 9.384530e-01]
[ 9.768190e-01 4.149409e+00]
[ 6.160510e-01 3.105444e+00]
[ 4.137000e-01 1.896278e+00]
[ 1.052790e-01 -1.213450e-01]
...
[ 1.200300e-02 -2.172830e-01]
[ 1.888300e-02 -3.005770e-01]
[ 7.147600e-02 6.014000e-03]]
我们在用图的形式查看,数据:
fig,ax = plt.subplots(figsize=(9,6))
ax.scatter(data1[:,0],data1[:,1])
plt.show()
得到:
这里我们可以看到明显可以数据可分为5类。
我们在用树回归函数,查看:
print(creatTree(data1))
得到:
{'splitFeature': 0, 'splitValue': 0.39435, 'left': {'splitFeature': 0, 'splitValue': 0.197834,
'left': -0.023838155555555553, 'right': 1.0289583666666666},
'right': {'splitFeature': 0, 'splitValue': 0.582002, 'left': 1.980035071428571,
'right': {'splitFeature': 0, 'splitValue': 0.797583, 'left': 2.9836209534883724, 'right': 3.9871632}}}
我们通过这个树,可以清楚看到叶节点的五个值:-0.023838155555555553
、1.0289583666666666
、1.980035071428571
、2.9836209534883724
、3.9871632
。这些值大致符合图中情况。
二、模型树
模型树的步骤大致和回归树相同,但是在切分完成之后,在对叶节点中的数据进行线性拟合,即对叶节点中的数据进行线性回归,比如如下数据图:
明显此数据可分为两段,但是两段数据又各有线性结构,我们只要给出每一段线性拟合的参数即可,叶节点的数据类型由代表样本平均值的数值变为保存参数的数组。而我们也只要对上面的回归树函数的参数errorType
和leafType
改变即可,errorType
改为线性回归的平方误差,leafType
改为叶节点的拟合参数。errorType
和leafType
对应的函数如下:
首先是每个节点线性拟合参数的函数:
def linerFit(DataSet):
X = DataSet[:,:-1]
y = DataSet[:,-1]
X_X = X.T @ X
if np.linalg.det(X_X)==0:
raise NameError('This mat can not inverse')
theta = np.linalg.inv(X_X) @ X.T @ y
return theta,X,y
接着是作为leafType
的函数:
def modelLeaf(dataSet):
theta,X,y = linerFit(dataSet)
return theta
作为errorType
的误差计算函数:
def modelError(dataSet):
theta,X,y = linerFit(dataSet)
error = (X @ theta- y).T @ (X @ theta - y)
return error
我们只要把回归树函数creatTree
中的参数errorType
设置为modelError
,leafType
设置为modelLeaf
即可。运行后,与回归树得到的结果进行比较,发现只是叶节点对应数据由数值变为储存参数数组,其他几乎无变化。