机器学习实战(第3章 决策树)
(代码下载地址:http://download.csdn.net/download/jichun4686/9944207)
3.0 概述
(1)目录
(2)简介:
决策树(decision tree)是一个树结构(可以是二叉树或非二叉树)。其每个非叶节点表示一个特征属性上的测试,每个分支代表这个特征属性在某个值域上的输出,而每个叶节点存放一个类别。使用决策树进行决策的过程就是从根节点开始,测试待分类项中相应的特征属性,并按照其值选择输出分支,直到到达叶子节点,将叶子节点存放的类别作为决策结果。
(3)优点:
决策过程非常直观,容易被人理解。目前决策树已经成功运用于医学、制造产业、天文学、分支生物学以及商业等诸多领域。
(4)缺点:
容易产生过度匹配的问题。
(5)方法:
只涉及ID3方法
3.1 决策树的构造
在学习决策树时,最重要的是构建决策树。其中,最重要的步骤是根据属性划分数据集,其中先使用哪个属性,后使用哪个属性,是决定决策树构建的好坏的重要标准。ID3方法选择的属性使划分后的信息增益最大。
这里就使用到一个概念:信息熵。
熵:表示随机变量不确定性,即混乱程度的量化指标。
熵越大,不确定性越大,越无序;越小,确定性越大,越有序。
同理,一条信息的信息量大小,与不确定性直接相关。
不确定性越大,信息量越大,熵越大;
确定性越大,信息量越小,熵越小。
我们划分数据集的大原则是:将无序的数据变为有序,即从熵较大的变为熵较小的,所以当差值即信息增益越大时,越能符合要求。
熵的计算公式:
3.1.1 信息增益
(1)给定一个数据集,计算该数据集的香农熵。
创建tree.py文件在其中写入calShannonEnt()和createDataSet()函数。
代码1:
from math import log #计算香农熵时候会用到log函数
#计算给定数据集的香农熵
def calShannonEnt(dataSet):
numEntries = len(dataSet) #计算总数
labelCounts = {} #创建空字典 包含分类结果和对应出现次数
#给字典赋值
for featVec in dataSet:
currentLabel = featVec[-1] #取当前行的最后一列
if currentLabel not in labelCounts.keys(): #判断是否在字典中
labelCounts[currentLabel] = 0 #不在则扩展字典将当前键值加入其中
labelCounts[currentLabel] += 1
#计算香农熵
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
运行结果1:
熵越高,混合的数据越高,将第一个数据中的类别改为’mybe’,以及将第一二个数据的类别均改为’no’,测试如下:
3.1.2 划分数据集
(2)按照给定特征划分数据集:
代码2:
#按照给定特征划分数据集
'''
dataSet:待划分的数据集
axis:划分数据集的特征 代表一个下标
value:特征的返回值
返回符合条件的划分出来的列表
'''
def splitDataSet(dataSet,axis,value):
retDataSet = [] #声明一个返回的空列表
for featVec in dataSet:
if featVec[axis] == value: #判断是否可以符合分类特征值
reducedFeatVec = featVec[:axis] #将除该特征之外的特征值分离出来
reducedFeatVec.extend(axis+1:) #extend是扩展该列表
retDataSet.append(reducedFeatVec)#append是将整个列表加入 作为一个子元素
return reversed #返回分离出来符合条件的数据集(这些数据是除去分类特征的)
运行结果2:
(3)选择最好的数据集划分方式:
思想:
即选不同的特征进行划分,计算其划分之后的香农熵,返回香农熵增益最大的特征即为其最好的划分方式。
代码3:
#选择最好的数据集划分方式
'''
输入一个数据集,返回使数据熵减小的最好的特征的索引值
'''
def chooseBestFeatureToSplit(dataSet):
numFeatures = len(dataSet[0]) - 1 #计算该数据集的特征数
baseEntropy = calShannonEnt(dataSet)#计算初始数据集的香农熵
bestInfoGain = 0.0 #定义并赋值最大的增益且赋值为0
#循环特征数进行用每一种特征情况下香农熵的计算
for i in range(numFeatures):
featList = [example[i] for example in dataSet] #将集合中的每i列元素组合成列表
#如i取0时:featList为[1, 1, 1, 0, 0]
#如i取1时:featList为[1, 1, 0, 1, 1]
uniqueVals = set(featList) #将列表中的重复元素删掉 只保留某特征的值 便于作为特征值来计算香农熵
newEntropy = 0.0
#计算香农熵
for value in uniqueVals:
subDataSet = splitDataSet(dataSet,i,value) #记录返回分类好的数据集
prob = len(subDataSet)/len(dataSet) #计算该子数据集在整体中所占的比重
newEntropy += prob*calShannonEnt(subDataSet) #累加划分子集后的每一种子集的香农熵
infoGain = baseEntropy - newEntropy
if(infoGain>bestInfoGain):
bestInfoGain = infoGain
bestFeature = i
return bestFeature #返回最好特征划分的索引值
运行结果3:
代表当前数据集的第0个特征是最好的用于划分数据集的特征。
3.1.3 递归构建决策树
(4)多数表决器
进行分类时,当被作为分类依据的属性值用完时,但是类标签仍然不是唯一的时,就要用到多数表决器,采用最多的作为其类别。
代码4:
#多数表决器,用于处理当用完了所有属性,但是类标签仍然不是唯一的
def majorityCnt(classList):
classCount = {} #新建一个字典
#统计类标签数目
for vote in classList:
if vote not in classCount.keys():
classCount[vote] = 0
classCount[vote] += 1
#对字典进行排序
sortedClassCount = sorted(classCount.items(),key = operator.itemgetter(1),reverse = True)
return sortedClassCount[0][0]
运行结果4:
使用classList = ['zc','zc','05','','mx','mx','mx']作为测试的列表,测试结果截图如下。
(5)进行递归创建决策树。
代码5:
#创建决策树
def createTree(dataSet,labels):
classList = [example[-1] for example in dataSet] #取出类别标签
#出口条件的判定
#条件一:当按照某一标签分好类之后,该分类中的类别均相同 即第一个元素的个数就等于该列表的总长度
if classList.count(classList[0]) == len(classList):
return classList[0]
#条件二:当不满足条件一时,即使用完了所有的标签,但是仍然不能将该数据集的类别划分为一致的
#此处请结合40页的图进行分析 如果出现这样的情况必定传进来的dataSet只剩下标签向量一列
if len(dataSet[0]) == 1:
return majorityCnt(classList) #使用多数表决器函数
#本层的处理
bestFeat = chooseBestFeatureToSplit(dataSet) #选出当前数据集中最好的分类特征下标
bestFeatLabel = labels[bestFeat] #对应的最好分类特征值
myTree = {bestFeatLabel:{}} #创建树根
del(labels[bestFeat]) #在labels中删除使用过的特征
featValues = [example[bestFeat] for example in dataSet] #取出当前分类标签的对应列
uniqueVals = set(featValues) #取出对应分类标签的值
for value in uniqueVals:
sublabels = labels[:] #因为参数是按照引用方式传递的,所以为了不改变原始列表中的内容
#本句话的理解就是bestFeatLabel的value为一棵树
myTree[bestFeatLabel][value] = createTree(splitDataSet(dataSet,bestFeat,value),sublabels)
return myTree #当完全遍历创建完树之后 进行的返回
运行结果5:
由截图结果可知,共有两个判断节点(子节点)和三个叶子结点。 其是用字典模拟的树,不够直观,所以下节中进行图形的绘制,使得该树可以更加直观的表示。
3.2 在python中使用Matplitlib注解绘制树形图
3.2.1 Matplotlib注解
(1)绘制简单的判断节点和叶子节点。
程序6:
import matplotlib.pyplot as plt
#这里是对绘制是图形属性的一些定义
#boxstyle为文本框的类型,sawtooth是锯齿形,fc是边框线粗细
decisionNode = dict(boxstyle = 'sawtooth',fc = '0.8') #定义decision节点的属性
leafNode = dict(boxstyle='round4',fc='0.8') #定义leaf节点的属性
arrow_args = dict(arrowstyle='<-') #定义箭头方向
#声明绘制一个节点的函数
'''
annotate是关于一个数据点的文本
nodeTxt:节点的内容
centerPt:文本的中心点 即数据点
parentPt:文本位置
nodeType:是判断节点还是叶子节点
'''
def plotNode(nodeTxt,centerPt,parentPt,nodeType):
createPlot.ax1.annotate(nodeTxt,xy = parentPt,xycoords='axes fraction',xytext = centerPt,
textcoords='axes fraction',va = 'center',ha='center',bbox = nodeType,arrowprops=arrow_args)
#声明绘制图像函数,调用绘制节点函数
def createPlot():
fig = plt.figure(1,facecolor='white') #新建绘画窗口 窗口名为figure1 背景颜色为白色
fig.clf() #清空绘图区
#创建了属性ax1 functionname.attribute的形式是在定义函数的属性,且该属性必须初始化,否则不能进行其他操作。
createPlot.ax1 = plt.subplot(111,frameon=False) #创建1行1列新的绘图区 且图绘于第一个区域 frameon表示是否绘制坐标轴矩形 True代表绘制 False代表不绘制
plotNode('a decision node',(0.5,0.1),(0.1,0.5),decisionNode)#调用画节点的函数
plotNode('a leaf node',(0.8,0.1),(0.3,0.8),leafNode )
plt.show() #画图
运行结果6:
输入createPlot()进行调用。
3.2.2 构造注解树
(2)求解叶子节点个数和树的高度。
若想正确确定x,y坐标,则需要知道树的高度和叶子结点的个数。
所以在treePlotter中加入了getNumLeafs(myTree)和getTreeDepth(myTree)函数以及为求简单引入的retrieveTree(i)函数来存储树的信息。
代码7:
#--------鉴于python3与python2的不同 python3 dict_keys支持iterable,而不支持indexable
#故先将myTree.keys()返回得到的dict_keys对象转化为列表
#否则会报TypeError: 'dict_keys' object does not support indexing的错误
#获得叶节点的数目
#是一个累加的过程
def getNumLeafs(myTree):
numLeafs = 0 #声明叶节点的个数
#python3
firstSides = list(myTree.keys()) #得到树所有键的列表
firstStr = firstSides[0] #取第一个键
#pyton2:firstStr = myTree.keys()[0]
secondDict = myTree[firstStr] #得到第一个键所对应的的值
for key in secondDict.keys(): #循环遍历secondDict的键
if type(secondDict[key]).__name__ == 'dict': #判断该键对应的值是否是字典类型
numLeafs += getNumLeafs(secondDict[key]) #若是则使用递归进行计算
else:
numLeafs += 1 #不是则代表当前就是叶子节点,进行加1即可
return numLeafs #返回叶子结点数目
#获得叶节点的深度
#道理同上,故不多叙述解释
#因为某一层不一定是最深的,所以引入thisDepth
#是一个求最值的过程
def getTreeDepth(myTree):
maxDepth = 0 #声明最大深度并赋值为0
firstSides = list(myTree.keys())
firstStr = firstSides[0]
secondDict = myTree[firstStr]
for key in secondDict:
if type(secondDict[key]).__name__ == 'dict':
thisDepth =1 + getTreeDepth(secondDict[key])
else:
thisDepth = 1
if thisDepth > maxDepth:
maxDepth = thisDepth
return maxDepth
#预先存储树的信息
def retrieveTree(i):
listOfTrees =[{'no surfacing': {0: 'no', 1: {'flippers': {0: 'no', 1: 'yes'}}}},
{'no surfacing': {0: 'no', 1: {'flippers': {0: {'head': {0: 'no', 1: 'yes'}}, 1: 'no'}}}}
]
return listOfTrees[i]
注意:
--------鉴于python3与python2的不同---------
python3 dict_keys支持iterable,而不支持indexable
故代码与书中略为不同,先将myTree.keys()返回得到的dict_keys对象转化为列表
否则会报TypeError: 'dict_keys' object does not support indexing的错误
运行结果7:
可以看到结果是叶子节点有三个,深度为2(开始为0)。符合。
(3)画出该树(plotTree树)
这个代码我认为对于初学者来说的确不容易理解,倒不是递归不容易理解,而是画图坐标的一些设定,花了些时间细细看,所有认识和理解均写于代码的注释中,希望和我一样不明白的小白可以整懂,有问题的地方欢迎提问。
代码铺垫:
(一)关于递归
三个步骤:
(1)绘制自身
(2)判断子节点非叶子节点,递归
(3)判断子节点为叶子节点,绘制
(二)关于绘图
本段代码在这些节点位置坐标的处理方面本人感觉是较为难懂的。
首先在createPlot函数中有声明一些成员变量:
plotTree.xOff : 绘制的叶子结点纵坐标(变量),只需要负责叶子结点即可。
plotTree.yOff : 当前绘制纵坐标(变量),可作为叶子节点和判断节点的纵坐标。
plotTree.totalW: 整棵树的叶子节点数(常量)
plotTree.totalD : 整棵树的深度(常量)
为了不让整棵树因为节点的增多或者是深度的深入而导致图形的混乱,采用了逆向的思维。让长度和宽度固定,利用叶子节点的个数来分配水平方向上的各个节点的位置,利用树的深度来分配竖直方向上节点的分布(以我们要绘制的树为例):
本段代码中我们将树所占的空间记为1*1大小。
水平方向上等分为叶子节点个数的份数,则一份的长度(即小长方形的长度)则为 1/plotTree.totalW.
竖直方向上等分为树深度值的份数,则一份的长度(即小长方形的宽度)则为 1/plotTree.totalD.
这样之后我们进行位置安放时候,对于最下边较多的叶子节点则可以平均的放置。
利用plotTree.xOff作为绘制叶子节点的横坐标,当再一次绘制叶子节点时横坐标直接加上两叶子节点的水平距离 1/plotTree.totalW即可。
利用plotTree.yOff作为当前绘制纵坐标,当需要发生改变时纵坐标接减去一层的距离 1/plotTree.totalD即可。
暂时铺垫这么多,下面让我们看代码,中间会有不好理解的地方会标明并放到后边讲解。
代码8:
#plotTree函数
#在父子节点间填充文本
'''
cntrPt:子位置坐标
parentPt:父位置坐标
txtString:文本信息
'''
def plotMidText(cntrPt,parentPt,txtString):
xMid = (parentPt[0]-cntrPt[0])/2.0 + cntrPt[0] #文本填充的x坐标
yMid = (parentPt[1]-cntrPt[1])/2.0 + cntrPt[1] #文本填充的y坐标
createPlot.ax1.text(xMid,yMid,txtString)#在(xMid,yMid)位置填充txtString文本
#画树的函数
'''
myTree: 要进行绘制的树
parentPt:父位置坐标
nodeTxt:文本内容
以下均为plotTree函数的成员变量 开始时候从上往下看的 竟然不知道这都是啥,看函数务必要先看入口 然后看调用了什么。
plotTree.xOff : 绘制的叶子结点纵坐标(变量),只需要负责叶子结点即可。
plotTree.yOff : 当前绘制纵坐标(变量),可作为叶子节点和判断节点的纵坐标。
plotTree.totalW: 整棵树的叶子节点数(常量)
plotTree.totalD : 整棵树的深度(常量)
'''
def plotTree(myTree,parentPt,nodeTxt):
numLeafs = getNumLeafs(myTree) #求得myTree的叶子的个数 注意这可不是我们之前所说的那颗最大的树 谁调用它谁是myTree
depth = getTreeDepth(myTree) #求得myTree的深度
#python3.6的原因,与书中有两行不一样
#-----
firstSides = list(myTree.keys())
firstStr = firstSides[0] #得到第一个键 也就是第一个判断节点 即myTree的根节点
#-----
cntrPt = (plotTree.xOff + (1.0 + float(numLeafs))/2/plotTree.totalW,plotTree.yOff) #计算子节点的坐标 其计算有一些需要说明白的,代码后重点讲,大家看完再往后看 记做难点①
plotMidText(cntrPt,parentPt,nodeTxt) #对判断节点进行的绘制其与其父节点之间的文本信息 此处第一个节点与父节点重合(0.5,1.0)的设置 所以会没有效果 也恰好符合题意
plotNode(firstStr,cntrPt,parentPt,decisionNode) #绘制子节点
secondDict = myTree[firstStr] #得到该节点以下的子树
plotTree.yOff = plotTree.yOff - 1.0/plotTree.totalD #深度改变 纵坐标进行减一层
#循环遍历各个子树的键值
for key in secondDict.keys():
if type(secondDict[key]).__name__ == 'dict': #如果该子树下边仍为一颗树(即字典类型)
plotTree(secondDict[key],cntrPt,str(key))#进行递归绘制
else:
plotTree.xOff = plotTree.xOff + 1.0/plotTree.totalW #要绘制下一个叶子节点 横坐标加小矩形的长度(见代码前的上图)
plotNode(secondDict[key],(plotTree.xOff,plotTree.yOff),cntrPt,leafNode)#绘制叶子节点 (plotTree.xOff,plotTree.yOff)代表叶子节点(子节点)坐标,cntrPt代表判断节点父节点坐标
plotMidText((plotTree.xOff,plotTree.yOff),cntrPt,str(key))#在父子节点之间填充文本信息
plotTree.yOff = plotTree.yOff + 1.0/plotTree.totalD #循环结束 递归回溯(此处大家不明白的 可以再复习一下递归) 因为本层中plotTree.yOff是不变的 所以当递归结束应该进行回溯
#声明绘制图像函数,调用绘制节点函数
def createPlot(inTree):
fig = plt.figure(1,facecolor='white') #新建绘画窗口 窗口名为figure1 背景颜色为白色
fig.clf() #清空绘图区
axprops = dict(xticks=[],yticks=[]) #定义横纵坐标轴
#创建了属性ax1 functionname.attribute的形式是在定义函数的属性,且该属性必须初始化,否则不能进行其他操作。
createPlot.ax1 = plt.subplot(111,frameon=False,**axprops) #创建1行1列新的绘图区 且图绘于第一个区域 frameon表示不绘制坐标轴矩形 定义坐标轴为二维坐标轴
plotTree.totalW = float(getNumLeafs(inTree)) #计算树的叶子数
plotTree.totalD = float(getTreeDepth(inTree)) #计算树的深度
plotTree.xOff = -0.5/plotTree.totalW #赋值给绘制叶子节点的变量为-0.5/plotTree.totalW 即为难点②
plotTree.yOff = 1.0 #赋值给绘制节点的初始值为1.0
plotTree(inTree,(0.5,1.0),'') #调用函数plotTree 且开始父节点的位置为(0.5,1.0) 难点③
plt.show() #画图
详解:
难点①:
cntrPt = (plotTree.xOff + (1.0 + float(numLeafs))/2/plotTree.totalW,plotTree.yOff)的由来
该公式用于计算判断节点的坐标,对于纵坐标,则是不需要计算的,最开始在最上边,按照递归的层次减即可,因此每一层直接使用递归前plotTree.yOff值即可,而相对复杂的是横坐标的值。下面对横坐标的值进行推导。
(1)本颗树的叶子节点所占的总长度:float(numLeafs) * 1.0/plotTree.totalW.
代表叶子节点总个数乘以每一个叶子节点所占的长度。
(2)当前节点位置:float(numLeafs) * 1.0/plotTree.totalW/2
因为当前节点总为递归到本层的根节点,总是在中心的位置,故除以2为其的位置。
(3)当前节点校正过的位置:float(numLeafs) * 1.0/plotTree.totalW/2 +1.0/plotTree.totalW/2
因为plotTree.xOff本来应该在原点,但是为了后期计算方便(进行绘制下一个叶子节点横坐标进行直接加上1.0/plotTree.totalW)赋初值为-0.5/plotTree.totalW,所以加上上一个叶子节点的坐标时需要加上1.0/plotTree.totalW/2 。所以最终结果为plotTree.xOff + (1.0 + float(numLeafs))/2/plotTree.totalW
难点②:
plotTree.xOff = -0.5/plotTree.xOff
因为我们希望叶子节点绘制到小矩形的中间位置较为美观,所以我们复制开始位置时候需要将其值减小矩形长度的一半,之后每次进行计算时只要加上一个小矩形的长度1/plotTree.xOff即可。
难点③:
plotTree(inTree,(0.5,1.0),'')
开始绘制根节点时候我们并没有父节点,所以让这个所谓的父节点和根节点进行重合,这样函数就不会产生多余的节点。
运行结果8:
运行代码输入以下测试代码1:
得到一棵树:myTree = retrieveTree(0)
进行绘制: createPlot(myTree)
输入以下测试代码2:
复制得到的树:changeTree = myTree
将树根节点中增加第三个分支:changeTree[‘no surfacing’][3] = ‘maybe’
绘制新树: createPlot(changeTree)
3.3 测试和存储分类器
暂不进行学习。
3.4 示例:使用决策树预测隐形眼镜类型
在 treePlotter.py中导入trees,可以使用createTree函数进行创建树。在treePlotter.py中定义函数plotLensesTree。
代码9:
#此处我将其写为一个函数plotLensesTree
def plotLensesTree(txtString):
fr = open(txtString) #打开文件
lenses = [inst.strip().split('\t') for inst in fr.readlines()]#读入文件内容并且写入列表中
lensesLabels = ['age','prescript','astigmatic','tearRate'] #记录标签
lensesTree = trees.createTree(lenses,lensesLabels) #创建树
createPlot(lensesTree) #绘图
运行结果9:
运行并输入plotLensesTree(‘lenses.txt’),得图如下。
因为这些匹配选项过于多,使得决策树较为庞大,我们称之为过度匹配,第九章的CART算法则可以解决这个问题。
总结:
决策树也花了一段时间,讲真,虽然进度很慢,但是慢慢整懂还是有些许的喜悦感的,下面大概对决策树进行一下总结。
决策树的原理无非就是将数据按照不同的特征进行分类,但是分类的话特征比较多,因此需要有一个的分类的标准,这时候我接触到了熵的概念,决策的目的是希望从无序转向有序,而无序到有序就是熵减小的一个过程,所以有了信息增益最大这一说法,接着逐个试探得到最佳一步一步最后得到有字典类型模拟的树。接着希望更加直观一些,所以进行了绘图,绘图时递归原理相同,只是坐标要稍加留心。最后运用实例来说明此算法的可行性和弊端。
还要注意的一点是python3和python2 略有不同,其中 python2中的 fristStr = myTree. keys()[0]要用firstSides = list(myTree.keys()) 、firstStr = firstSides[0]来代替,有和我一样用python3的需要注意。
下一步计划是偏最小二乘回归算法,计划9.31时吃透 其以及和它有关的算法。
【偏最小二乘回归 2017.8.21-2017.8.31】