【摘要】
本文对应《机器学习实战》——第三章和第九章:决策树和树回归
对应周志华《机器学习》第四章:决策树
内容大纲:
第三章:决策树简介、数据集中度量一致性、递归构造决策树、Matplotlib绘制树形图;
第九章:CART算法、回归与模型树、树剪枝算法以及Python中GUI的使用
【引言】决策树的概念就是:给定一组数据(正例和反例),根据一系列推断规则,将数据的分类结果反馈给用户。
目前最经常使用的数据挖掘算法便是决策树算法,也常用其来处理分类问题,最根本的原因是决策树并不需要很了解机器学习的相关知识,甚至只要明白最简单的二分类条件选择语句便可以构造一棵简单的决策树。
在决策树的流程图中,长方形代表判断模块,椭圆形代表终止模块,箭头被称作分支。决策树的主要优势在于数据形式非常容易理解。
决策树的一个重要任务是为了理解数据中所蕴涵的知识信息,因此决策树可以使用不熟悉的数据集合,并从中提取一系列规则。机器根据数据集创建规则的过程就是机器学习的过程。
决策树的基本概念及其构造规则:
优点:计算复杂度不高,输出结果易于理解,对中间值的缺时不敏感,可以处理不想管特征数据;
缺点:可能会产生过度匹配问题。
适用数据类型:数值型、标称型。
在构建决策树的时候,我们需要知道当前数据集上哪个特征在划分数据分类时起到了决定性的作用,为此我们需要评估每个特征。完成测试后,原始数据集就被划分为几个数据子集。这些子集会分布在第一个决策点的所有分支上(e.g:色泽:乌黑 D 1 ~ \tilde{D^1} D1~、青绿 D 2 ~ \tilde{D^2} D2~、浅白 D 3 ~ \tilde{D^3} D3~)。如果某个分支下的数据属于同一类型,则无需进一步对数据集进行分割;若数据子集内的数据不属于同一类型,则继续重复划分过程,知道所有具有相同类型的数据均在一个数据子集内。
决策树创建分支伪代码函数(递归函数)createBranch():
检测数据集中的每个子项是否属于同意分类:
if so return 类标签
else
寻找划分数据集的最好特征
划分数据集
创建分支结点
for 每个划分的子集
调用函数createBranch并增加返回结果到分支结点中
return 分支结点
其划分步骤如下:
(1)收集数据:任何方法
(2)准备数据:树构造算法只适用于标称型数据,因此数值型数据需要使用连续属性离散化技术处理
(3)分析数据:任何方法,构造树完成后利用可视化观察图形是否符合预期
(4)训练算法:构造树的数据结构
(5)测试算法:使用经验树计算错误率
(6)使用算法:可适用于任何监督学习算法,且易于理解数据内涵
决策树的划分规则并不局限于二分法,因为可能会根据某个属性划分出多个可能的值。这里采用ID3算法,每次划分数据集时只选取一个特征属性,然后可能需要采用一些手段将二值数据、离散数据经过处理后再进行划分。
【《西瓜书》回顾】
①信息熵:是度量样本集合纯度最常用的一种指标。
E n t ( D ) = − ∑ k = 1 ∣ Y ∣ p k l o g 2 p k Ent(D) = -\sum_{k=1}^{|Y|}p_klog_2p_k Ent(D)=−k=1∑∣Y∣pklog2pk
熵的值越小,则D的纯度越高。
②信息权重:由于决策树是根据离散属性a来进行划分的,因此有多少的离散属性一般就会产生多少个分支结点,而可以计算每一个分支结点(划分属性)上的信息上Dv,其中V表示离散属性a可能的取值{a1, a2, …, aV}。考虑到不同分支结点所包含的样本数不同,给分支结点赋予权重: W = ∣ D V ∣ ∣ D ∣ W = \frac {|D^V|}{|D|} W=∣D∣∣DV∣DV为属于V类数据集的信息熵,D为整体数据集的信息熵。可见样本数越多的分支结点影响越大,因为样本数越多,Ent(DV)的值越大,自然上述权重W的值越大。
③信息增益:而对于整个样本空间D而言,可以计算出用属性a对样本集D进行划分所获得的信息增益。
G a i n ( D , a ) = E n t ( D ) − ∑ v = 1 V ∣ D V ∣ D E n t ( D v ) Gain(D, a)=Ent(D)-\sum_{v=1}^{V} \frac{|D^V|}{D}Ent(D^v) Gain(D,a)=Ent(D)−v=1∑VD∣DV∣Ent(Dv)
可以看出,该式的含义在于:信息增益=样本空间的信息熵 - (离散属性a的v各分支对应的信息熵 * 对应样本空间中权重)之和 。
因此,获得信息增益最高的特征就是最好的选择。为了计算给定数据集的信息熵,创建以下代码:
程序清单
trees.py
#3.1 计算给定数据集的信息熵
from math import log
def calcShannonEnt(dataSet):
//计算数据集中实例的总数
numEntries = len(dataSet)
//创建数据字典,键值是数据集中最后一列的数值
labelCounts = {}
//遍历数据集,如果当前键值不存在,则扩展字典并将当前键值加入字典。便于计算所有类别的出现频率,从而计算信息熵
//最后遍历结果给labelCounts = {'yes' : 2, 'no' , 3}表示yes和no分别出现的次数
for featVec in dataSet:
currentLabel = featVec[-1]
if currentLabel not in labelCounts.keys():
labelCounts[currentLabel] = 0
labelCounts[currentLabel] += 1
//计算信息熵:Ent(D) = -Σpk*log2pk
shannonEnt = 0.0
for key in labelCounts:
prob = float(labelCounts[key])/numEntries
shannonEnt -= prob * log(prob, 2)
return shannonEnt
------------------------------------------
#创建一个简单的“鱼鉴定”数据集
def createDataSet():
dataSet = [[1, 1, 'yes'],
[1, 1, 'yes'],
[1, 0, 'no'],
[0, 1, 'no'],
[0, 1, 'no']]
labels = ['no surfacing', 'flippers']
return dataSet, labels
执行代码
myDat, labels = createDataSet()
myData
calcShannonEnt(myDat)
结果返回Ent(D) = 0.97095059445466858
信息熵越大,混合的数据也越多,纯度也越高。可以引入以下代码将数据集从二类问题变为三类问题,观察信息熵的变化:
//将第一组数据[1, 1, 'yes'] 改为 [1, 1, 'maybe']
myDat[0][-1] = ‘maybe’
calcShannonEnt(myDat)
结果为1.3709505944546687。得到信息熵后就可以按照获取最大信息增益的方法划分数据集。
【注】:另一种度量方式为基尼系数:即从数据集中随机选取子项,度量其被错误分类到其他分组里的概率。
公式回顾:
G i n i ( D ) = 1 − ∑ k = 1 ∣ Y ∣ p k 2 Gini(D)=1-\sum_{k=1}^{|Y|}p_k^2 Gini(D)=1−k=1∑∣Y∣pk2
为了实现分类算法,我们需要测量信息熵Ent(D)、划分数据集(D1、D2…Dv)、度量划分数据集的熵(Ent(D1)、Ent(D2)…Ent(Dv))。同时针对不同的特征,我们需要计算不同的信息熵,从而决定按哪个特征划分数据集是最好的划分方式。
程序清单
trees.py
#3.2 按照给定特征划分数据集
def splitDataSet(dataSet, axis, value):
retDataSet = []
//遍历dataSet中的每一个样本
for featVec in dataSet:
//如果每个样本下标为axis那一列数值为value,目的是将符合要求的元素抽取出来
if featVec[axis] == value:
//“:axis”表示≤axis的对象
reducedFeatVec = featVec[:axis]
//“axis+1:”表示大于axis+1的对象
reducedFeatVec.extend(featVec[axis+1:])
retDataSet.append(reducedFeatVec)
return retDataSet
python中append()方法和extend()方法功能类似,但是append只是添加元素,而extend可以衔接列表。
执行命令
myDat, labels = createDataSet()
myDat
//输出每个样本第一列==1的样本及它所属类别
splitDataSet(myDat, 0, 1)
>>>[[1, 'yes'], [1, 'yes'], [0, 'no']]
//输出每个样本第一列==0的样本及它所属类别
splitDataSet(myDat, 0, 0)
>>>[[1, 'no'], [1, 'no']]
接下来遍历整个数据集,循环计算信息熵和splitDataSet()函数,利用信息熵找到最好的划分方式。
程序清单
trees.py
#3.3 选择最好的数据集划分方式
def chooseBestFeatureToSplit(dataSet):
numFeatures = len(dataSet[0]) - 1
baseEntropy = calcShannonEnt(dataSet)
bestInfoGain = 0.0; bestFeature = -1
//计算各属性的信息增益
for i in range(numFeatures):
featList = [example[i] for example in dataSet]
uniqueVals = set(featList)
newEntropy = 0.0
for value in uniqueVals:
subDataSet = splitDataSet(dataSet, i, value)
prob = len(subDataSet)/float(len(dataSet))
newEntropy += prob * calcShannonEnt(subDataSet)
infoGain = baseEntropy - newEntropy
//筛选最优划分属性
if(infoGain > bestInfoGain):
bestInfoGain = infoGain
bestFeature = i
return bestFeature
遍历当前特征中的所有唯一属性值,对每个唯一属性值划分一次数据集,然后计算数据集的新熵值,并对所有唯一特征值得到的熵求和。信息增益是熵的减少或者数据无序度的减少。最后,比较所有特征中的信息增益,返回最好特征划分的所引值。
myDat, labels = createDataSet()
chooseBestFeatureToSplit(myDat)
>>>0
从上述代码可以看出,第A个特征是最好的用于划分数据集的特征。回顾一下,数据集为:
编号 | 特征A | 特征B | 类别 |
---|---|---|---|
0 | 1 | 1 | yes |
1 | 1 | 1 | yes |
2 | 1 | 0 | no |
3 | 0 | 1 | no |
4 | 0 | 1 | no |
显然,按照第一类划分,2个被分为yes,2个被分为no,只有一个可能造成误分。因此第一种划分可以很好地处理相关数据。
def createTree(dataSet, labels):
classList = [example[-1] for example in dataSet]
if classList.count(classList[0]) == len(classList):
return classList[0]
if len(dataSet[0]) == 1:
return majorityCnt(classList)
bestFeat = chooseBestFeatureToSplit(dataSet)
bestFeatLabel = labels[bestFeat]
myTree = {bestFeatLabel:{}}
del(labels[bestFeat])
featValues = [example[bestFeat] for example in dataSet]
uniqueVals = set(featValues)
for value in uniqueVals:
subLabels = labels[:]
myTree[bestFeatLabel][value] = createTree(splitDataSet(dataSet,bestFeat,value),subLabels)
return myTree
myTree包含了许多代表树结构信息的嵌套字典并存储了树的所有信息,当前数据集选取的最好特征存储在变量bestFeat中,得到列表包涵的所有属性值。
开始处理数据集时:
首先,需要测量集合中数据的不一致性,即熵
然后,寻找最优划分方案对数据集进行划分
其次,循环递归,知道数据集中所有数据属于同一分类
本章主要对ID3算法进行了实现,其可以用来划分标称型数据。第九章将对CART算法进行介绍,另外的决策树算法还有C4.5算法。
【引言:前接第八章<回归>】
尽管局部加权线性回归的效果很好,但是许多线性回归在创建模型的过程中需要拟合所有的样本点。因此,当数据拥有众多特征并且关系十分复杂时,构建全局模型的想法就显得很困难且笨拙了。生活中也是如此,许多问题都是非线性的,不能使用全局线性模型来拟合数据。
【注:ID3、C4.5在处理连续型数据时,都会利用连续数值离散化技术,这回破坏数据本身的性质,而CART方法本身采用二元切分可以避免此现象。因此ID3和C4.5算法被称为分类树,而CART算法被称为回归树】
结合本博客上半部分“决策树”章节所提到的,决策时进行分类时,**不断将数据切分成小数据集,直到所有目标变量完全相同,或者数据不能再切分为止。**这是一种贪心算法,要在给定时间内做出最佳选择,但并不关心能否达到全局最优。
树回归的特点:
优点:可以对复杂和非线性的数据建模;
缺点:结果不易理解;
适用数据类型:数值型和标称型数据
已知ID3的做法是利用信息增益,每次选取当前最佳的特征来分割数据,并按照该特征的所有可能取值来对数据集进行切分(如果对于特征<色泽>有3种取值:乌黑,浅白,青绿。则该数据集会被切分成四份)。ID3算法切分非常迅速,但无法处理连续型特征,因此会运用连续特征离散化的技术,类似于二分阈值。但这样的方法会破坏变量本身内在的性质。
当然还存在另外一种切分方法:二元切分法。类似于二分法,取一个阈值,每次把数据集切成两份。当特征值大于等于切分要求时,数据进入左子树,反之进入右子树。二元切分法也节省了树的构建时间。但其实时间因素影响不大,因为建树过程一般都是离线完成的。
正如我在《西瓜书》第四章:回归中所总结的,ID3利用的是信息增益;C4.5利用的是增益率。因此,CART算法使用的就是基尼系数。CART算法作为广为记载的树构建方法,使用二元切分来处理连续型变量。因此,对其稍作修改就可以处理回归问题。CART方法主要采用的是基尼系数(Gini)来代替围绕信息熵展开的计算过程。
回归树与分类树的思路类似,只是叶结点的数据类型不是离散型,而是连续型。
(1)收集数据:采用任意方法收集数据
(2)准备数据:需要数值型的数据,标称型数据应该映射成二值型数据
(3)分析数据:二维可视化,以字典方式生成
(4)训练算法:大部分时间都花费在叶结点树模型的构建上
(5)测试算法:使用测试数据来分析模型效果
(6)使用算法:使用训练出的树预测,可以利用预测结果做很多事情
利用CART算法做二元切分,可以固定树的数据结构,包含左键和右键,可以存储另一棵子树或者单个值。利用字典的形式存储,将会包含四个元素:待切分的特征、待切分的特征值、左子树和右子树。而本章所涉及到的CART算法中,根据叶结点的结构,分为回归树(叶结点是单个值)和模型树(叶结点是线性方程)。
程序清单:
regTrees.py
from numpy import *
#读取数据,并以tab键为分隔符,将每行内容保存成一组浮点数。
def loadDataSet(fileName):
dataMat = []
fr = open(fileName)
for line in fr.readline():
curLine = line.strip().split('\t')
fltLine = map(float, curLine)
dataMat.append(fltLine)
return dataMat
#在给定特征和特征值的情况下,通过数组过滤的方式将上述数据集合切分得到两个子集并返回
def binSplitDataSet(dataSet, feature, value):
mat0 = dataSet[nonzero(dataSet[:,feature] > value)[0],:]#[0]
mat1 = dataSet[nonzero(dataSet[:,feature] <= value)[0],:]#[0]
return mat0, mat1
#createTree作为书回归树和模型树的公用函数,其可重用代码如下:
#leafType给出建立叶结点的函数
#errType代表误差计算函数
#ops是一个包含树构建所需其他参数的元组
def createTree(dataSet, leafType = regLeaf, errType = regErr, ops=(1,4)):
#首先利用chooseBestSplit()将数据集划分成两个部分
feat, val = chooseBestSplit(dataSet, leafType, errType, ops)
#若满足停止条件,则返回叶结点值
#若不满足则继续调用createTree()递归
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
#对于回归树和模型树,返回的结果不同。
#回归树返回一个常数;模型树返回一个线性方程。
回归树假设叶结点是常数值,该策略认为数据中的复杂关系可以用树结构来概括。上文第三章中的决策树进行分类的过程中会在给定结点时计算数据的混乱度。
混乱度求解:
(1)计算所有数据的均值
(2)计算每条数据的值到均值的差值(一般使用绝对值或平方值来代替差值)
这样的做法类似于求方差,此处求的是总方差(平方误差的总和),而一般我们求的是均方差。
实现chooseBestSplit()函数,用以最佳方式切分数据集和生成响应的叶结点。该函数需要给定某个误差计算方法,找到数据集上最佳的二元切分方式;此外还需确定什么时候停止切分,继而生成叶结点。
该函数的工作原理是遍历所有的特征及其可能的取值来找到误差最小化的切分阈值。
伪代码如下:
对每个特征:
对每个特征值:
将数据集切分成两份
计算切分的误差
若当前误差小于当前最小误差,则将当前切分设定为最佳切分并更新最小误差返回最佳切分的特征和阈值
程序清单:
retTree.py
def regLeaf(dataSet):
dataSet = dataSet.astype(float)
return mean(dataSet[:,-1].astype(float))
#误差估计。先利用均方差函数var对目标变量求均方,因为要返回总方差,故用均方差*数据集样本的个数
def regErr(dataSet):
dataSet = dataSet.astype(float)
return var(dataSet[:,-1]) * shape(dataSet)[0]
#ops用于控制函数的停机时间。tolS是容许的误差下降值,tolN是切分的最少样本数.
def chooseBestSplit(dataSet, leafType=regLeaf, errType=regErr, ops=(1,4)):
tolS = ops[0]; tolN = ops[1]
if len(set(dataSet[:,-1].T.tolist()[0])) == 1: #exit cond 1
#如果找不到一个好的二元切分,则返回None值并继续调用createTree函数。下同
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)
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 the decrease (S-bestS) is less than a threshold don't do the split
if (S - bestS) < tolS:
return None, leafType(dataSet) #exit cond 2
mat0, mat1 = binSplitDataSet(dataSet, bestIndex, bestValue)
if (shape(mat0)[0] < tolN) or (shape(mat1)[0] < tolN): #exit cond 3
return None, leafType(dataSet)
return bestIndex,bestValue#returns the best feature to split on
#createTree作为书回归树和模型树的公用函数,其可重用代码如下:
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
执行代码
from numpy import *
myDat = regTrees.loadDataSet('ex00.txt')
myMat = mat(myDat)
createTree(myMat)
myDat1 = loadDataSet('ex0.txt')
myMat1 = mat(myDat1)
createTree(myMat1)
一棵树如果结点过多,则有可能对数据进行了过拟合,正如之前为了避免出现过拟合而使用交叉验证的方法一样,决策树也是如此。只不过决策树通过降低复杂度来避免过拟合,这样的过程被称为剪枝,分为预剪枝和后剪枝。预剪枝在之前的代码部分chooseBestSplit()中的提前终止条件已经实现了,而针对测试集和训练集剪枝的过程就是后剪枝。
虽然chooseBestSplit()函数的结果令人满意,但是在一般情况下,树构建算法对输入参数tolS和tolN其实非常敏感。因为之前我们预设了ops=(1, 4),但是如果改为(0,1)那么效果将会天差地别。
有时数据集构建的新树有很多叶结点而有时只有两个结点,产生这现象的原因在于停止条件tolS对误差的数量级十分敏感。如果在选项中花费时间并对上述误差容忍度取平方之,也许能得到仅有两个结点的新树。
使用后剪枝方法需要将数据集分成测试集和训练集,因为不需要用户指定参数,因此后剪枝是一个更理想化的剪枝方法。
首先指定参数,使得构建出的树足够大且复杂,接下来自上而下找到叶结点,用测试集来判断这些结点合并是否能降低测试误差。
函数prune的伪代码如下:
基于已有的树切分测试数据:
如果存在任一子集是一棵树,则在该子集递归剪枝过程
计算将当前两个叶结点合并后的误差
计算不合并的误差
如果合并会降低误差的话,就将叶结点合并
程序清单9-3
regTrees.py
def isTree(obj):
return (type(obj).__name__=='dict')
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
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['spInde'],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'])
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:
print('merging')
return treeMean
else: return tree
else: return tree
prune()函数的作用就是检查分支到底是子树还是结点。利用prune()对子树进行剪枝,如果两个分支不再是子树,那么就可以进行合并。通过比较合并前后的误差来判断。