讲在前面:上一篇我们讲述了《机器学习实战》的K-近邻算法,刚好最近学习了第三章的决策树,既然开了前一章的头,那么我久继续将这类博客写下去,对自己的知识也可以巩固提高,也希望可以对我这样的小白也有帮助。
决策树是一种基本的分类和回归的方法。我们经常使用决策树处理分类问题,决策树也是最经常使用的数据挖掘算法。其实决策树的工作原理很简单,我们可以通过简单的图形来了解:
上图我们用一个邮件的分类系统来大致讲述其工作原理,正方形代表的是判断模块,椭圆形代表的是终止模块,表示的是已经得出的结论,可以终止运行,从判断模块引出的左右两条线是称为分支,它可以到达另一个判断模块或者是终止模块。当然这是书上的解释,我搜索资料时也记录了另外一种理解,决策树模型是一种描述对实例进行分类的树形结构。就与树的结构类似,由结点和有向边组成(当然上图忘记加上箭头了),同时,结点有内部结点和叶结点两种,内部结点表示的是一个特征或属性,叶结点表示的是一个分类,就是判断模块属于内部结点,终止模块是叶结点,分支就是有向边。就如这个邮件分类系统,它首先检测发送邮件的域名地址,如果是myEmployer.com,那么就将它分类为无聊时需要阅读的邮件,不是的话它就继续检测,如果包含单词“曲棍球”的邮件,就分类为需要及时处理的朋友邮件,否则就划分为无需阅读的垃圾邮件。它其实可以看成if - then的规则。那这就是决策树的大概原理。
决策树的很多的任务都是为了数据中所蕴含的知识信息,因此决策树可以使用不熟悉的数据集合,并从中提取出一系列的规则,机器学习算法最终将使用这些机器从数据集中创造的规则。专家系统中经常使用决策树,而且决策树给出结果往往可以匹敌在当前领域具有几十年工作经验的人类专家。
决策树的优缺点
优点:计算复杂程度不高,输出结果易于理解,对中间值的缺失不敏感,可以处理不相关特征数据。
缺点:可能产生过度匹配的问题
适用数据类型:数值型和标称型
决策树的流程
1.收集数据:可以使用任何方法
2.准备数据:树构造算法只适用于标称型数据,因此数值型数据必须离散化
3.分析数据:可以使用任何方法,构造树完成后,我们应该检查图形是否符合预期
4.训练算法:构造树的数据结构
5.测试算法:使用经验树计算错误率
6.使用算法:此步骤可以适用于任何监督学习算法(算法必须知道预测什么,即目标变量的分类信息),而适用决策树可以更好地理解数据的内在含义
构造决策树时,我们需要解决的第一个问题就是当前数据集上哪个特征在划分数据分类时起决定性的作用,为了找出决定性的特征,划分出最好的结果,我们必须要评估每个特征。如果某个分支下的数据属于统一类型,则当前无需阅读的垃圾邮件已经正确地划分数据分类,无需进一步对数据集进行分割,如果数据子集内的数据不属于同一类型,则需要重复划分数据子集的过程。如何划分数据子集的算法和划分原始数据集的方法相同,直到所有具有相同类型的数据均在一个数据子集内。而我们也将是采用ID3算法划分数据集。(ID3算法是一种贪心算法,用来构造决策树。ID3算法起源于概念学习系统(CLS),以信息熵的下降速度为选取测试属性的标准,即在每个节点选取还尚未被用来划分的具有最高信息增益的属性作为划分标准,然后继续这个过程,直到生成的决策树能完美分类训练样例。)
不浮出水面是否可以生存 | 是否有脚蹼 | 属于鱼类 | |
---|---|---|---|
1 | 是 | 是 | 是 |
2 | 是 | 是 | 是 |
3 | 是 | 否 | 否 |
4 | 否 | 是 | 否 |
5 | 否 | 是 | 否 |
对于我们上述划分数据集,我们是选择不浮出水面是否可以生存这个特征作为决定性特征,还是以是否具有脚蹼,这就需要采用量化的方法去判断,同时对于特征的选择在于选取对训练数据具有分类能力的特征,如果利用的一个特征进行分类的结果与随机分类的结果没有很大的差别,那么称这个特征是没有分类能力的,因为它的效率不高。通常特征选择的标准是信息增益,所以接下来我们对此来介绍。
信息增益
划分数据集的最大原则是将无序的数据变得更加有序。同时在划分数据集之前之后信息发生的变化称为信息增益,计算每个特征划分数据集获得的信息增益,获得信息增益最高的特征就是最好的选择。
香农熵
但是在评估哪种数据划分方式是最好的数据划分之前,就得知道如何计算信息增益。集合信息的度量方式称为香农熵或者简称为熵。如果看不太明白什么是信息增益和熵,不要着急,它们自诞生的那天起,就注定会令世人不解。
熵定义为信息的期望值,在信息论与概率统计中,熵是表示随机变量不确定性的度量,同时我们也必须知道信息的定义,如果待分类的事务可能划分在多个分类中,则符号xi信息定义为: I ( x i ) = − l o g 2 p ( x i ) I(x_i) =-log_2p(x_i) I(xi)=−log2p(xi)
其中p(xi)是选择该分类的概率。通过上面的公式我们可以的到所有类别的信息,当然我们计算熵,还需要计算所有类别所有可能值包含的信息期望值即数学期望,公式如下: H = − ∑ i = 1 n p ( x i ) l o g 2 p ( x i ) H = -\sum_{i=1}^np(x_i)log_2p(x_i) H=−i=1∑np(xi)log2p(xi)
其中n是分类的数目。熵越大,随机变量的不确定性就越大,混合的数据也月多。
当熵中的分类的概率是由数据估计得到时,所对应的熵叫做经验熵。对于其中的数据估计,我们举个例子,就拿上面的表格5个数据来说,有是鱼类和不是鱼类两种类别,其中是鱼类的数据有2个,不是鱼类的类别有3个,所以式鱼类的概率是2/5,不是鱼类的概率是3/5,也就是说这些概率都是我们根据计算得出的,我们定义我们表中的数据是训练集D,则其经验熵的表达为H(D),|D|表示它的样本个数,设有k个分类hk,k=1,2,3…k,|hk|是表示类hk的个数,所以,经验熵的表达计算公式可以写成: H ( D ) = − ∑ k = 1 k ∣ h k ∣ ∣ D ∣ log 2 ∣ h k ∣ ∣ D ∣ H(D) =-\sum_{k=1}^k\frac{|hk|}{|D|}\log_2\frac{|hk|}{|D|} H(D)=−k=1∑k∣D∣∣hk∣log2∣D∣∣hk∣
所以训练集中的数据的经验熵为: H ( D ) = − 2 5 l o g 2 2 5 − 3 5 l o g 2 3 5 = 0.97095 H(D) =-\frac{2}{5}log_2\frac{2}{5}-\frac{3}{5}log_2\frac{3}{5}=0.97095 H(D)=−52log252−53log253=0.97095所以它的经验熵就是0.97095。
我们也可以用python来计算数据集得香农熵:
from math import log
from numpy import *
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#返回数据集和分类属性
def calcShannonEnt(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
#返回香农熵
if __name__=='__main__':
myData,labels = createDataSet()
print(calcShannonEnt(myData))
运行上述代码得到结果:
上述结果与我们之前算的结果也是一致的。
划分数据集
我们了解了如何计算数据集的熵,但是分类算法除了需要测量信息熵,还需要划分数据集,度量花费数据集的熵,以便判断是否正确划分了数据集。所以我们将对每个特征划分数据集的结果计算一次信息熵,然后判断按照哪个特征划分数据集是最好的划分方式。所以我们需要编写一段代码,将数据集按照给定的特征划分。
def splitDataSet(dataSet,axis,value):#按照给定的特征划分数据集
#参数是待划分的数据集,给定的划分数据集的特征,特征的返回值
retDataSet = []
#创建返回的数据集列表,为了不修改原始的数据集
for featVec in dataSet:#对数据集进行遍历
if featVec[axis] == value:
#给定特征返回值符合要求
reducedFeatVec = featVec[:axis]
reducedFeatVec.extend(featVec[axis+1:])
#去掉axis特征,将符合条件的数据集添加到新的数据集
retDataSet.append(reducedFeatVec)
#将结果添加到要返回的数据集中
return retDataSet#返回划分后的数据集
对于书中给定的例子我们根据代码也很好理解,比如splitDataSet(myDat,0,1),就是将第一列的特征值为1的提取,并且去掉了这个给定的特征,所以结果就是[[1,‘yes’],[1,‘yes’],[0,‘no’]]。接着我们需要遍历整个数据集,然后循环计算其香农熵和splitDataSet()函数进行划分,找到最好的划分方式。我们根据熵的值来选择最好的数据组织方式,所以可以编写代码:
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]
#获取dataSet的第i个所有特征放入列表中
uniqueVals = set(featList)
#创建set集合,元素不可以重复
#用set是因为每一列有些特征值是相同的
newEntropy = 0.0
#定义经验条件熵
for value in uniqueVals:#计算信息增益
#对特征值进行遍历
subDataSet = splitDataSet(dataSet,i,value)
#对每个特征划分一次数据集,subDataSet为划分后子集
prob = len(subDataSet)/float(len(dataSet))
#计算子集的概率
newEntropy += prob*calcShannonEnt(subDataSet)
#根据公式计算经验条件熵
infoGain = baseEntropy - newEntropy
#获得信息增益
if infoGain > bestInfoGain:#如果当前信息增益大于最初
bestInfoGain = infoGain
bestFeature = i
#更新信息增益,并且记录信息增益最大的特征索引值
return bestFeature#返回信息增益最大的特征的索引值
可能很多人对上面的代码有点不懂,其中涉及到的信息增益的计算和经验条件熵的介绍书中都没有提及,所以我在这做个简单的介绍,有兴趣的伙伴可以自行去搜索深入了解。
前面说到的选择特征就是要看信息增益,信息增益越大,特征对最终的分类结果影响也越大,我们就该选择对最终分类结果影响最大的那个特征作为我们的分类特征。
在这之前我们提一个概念,条件熵,这与条件概率有些不同,条件熵H(Y|X)表示在已知随机变量X的条件下随机变量Y的不确定性,随机变量X给定的条件下随机变量Y的条件熵H(Y|X),定义X给定条件下Y的条件概率分布的熵对X的数学期望: H ( Y ∣ X ) = ∑ i = 1 n p i H ( Y ∣ X = x i ) H(Y|X)=\sum_{i=1}^{n}p_iH(Y|X=x_i) H(Y∣X)=i=1∑npiH(Y∣X=xi)
其中的 p i = P ( X = x i ) , i = 1 , 2 , . . . n p_i=P(X=x_i),i=1,2,...n pi=P(X=xi),i=1,2,...n跟上面的经验熵道理一样,当条件熵中的概率由数据估计得到的时候,所对应的条件熵也就成为了条件经验熵。
再者,信息增益是相对于特征而言的,所以,特征A对训练数据集D的信息增益g(D,A),定义为集合D的经验熵H(D)与特征A给定下D的经验条件熵H(D|A)之差, g ( D , A ) = H ( D ) − H ( D ∣ A ) g(D,A)=H(D)-H(D|A) g(D,A)=H(D)−H(D∣A)一般的,熵与条件熵之差是互信息,决策树学习中的信息增益等价于训练数据集中类与特征的互信息。
我们定义一下特征A有n个不同的取值{a1,a2,a3…an},根据特征A的取值将D划分为n个子集D1,D2,D3,…Dn,|Di|为Di的样本个数。子集Di中属于类hk的样本的集合为Dik,即Dik是DI与hk的交集,|Dik|为Dik的样本个数,于是经验条件熵的公式可以写为 H ( D ∣ A ) = ∑ i = 1 n ∣ D i ∣ ∣ D ∣ H ( D i ) = − ∑ i = 1 n ∣ D i ∣ ∣ D ∣ ∑ k = 1 k ∣ D i k ∣ ∣ D i ∣ l o g 2 ∣ D i k ∣ ∣ D i ∣ H(D|A)=\sum_{i=1}^{n}\frac{|D_i|}{|D|}H(D_i)=-\sum_{i=1}^{n}\frac{|D_i|}{|D|}\sum_{k=1}^{k}\frac{|D_ik|}{|D_i|}log_2\frac{|D_ik|}{|D_i|} H(D∣A)=i=1∑n∣D∣∣Di∣H(Di)=−i=1∑n∣D∣∣Di∣k=1∑k∣Di∣∣Dik∣log2∣Di∣∣Dik∣
我们再来举个例子理解,还是以那个表格的数据集为例,这个不浮出水面是否可以生存这一列,也就是第一个特征,也就有两个类别,是和否,这个特征值为是的也就是有3个数据,所以不浮出水面是否可以生存的数据在训练数据集出现的概率是3/5,那么这个特征值为否的概率就是2/5,我们再看不浮出水面能生存的数据最终定义为属于鱼类的是2/3,为否的最终定义为鱼类的概率是0,所以计算出不浮出水面是否可以生存的信息增益, g ( D , A 1 ) = H ( D ) − H ( D ∣ A 1 ) = H ( D ) − [ 3 / 5 H ( D 1 ) + 2 / 5 H ( D 2 ) ] = 0.971 − [ 3 / 5 ( − 2 / 3 l o g 2 2 / 3 − 1 / 3 l o g 2 1 / 3 ) + 2 / 5 ( 0 − l o g 2 1 ) ] g(D,A_1)=H(D)-H(D|A_1)=H(D)-[3/5H(D1)+2/5H(D2)]\\=0.971-[3/5(-2/3log_22/3-1/3log_21/3)+2/5(0-log_21)] g(D,A1)=H(D)−H(D∣A1)=H(D)−[3/5H(D1)+2/5H(D2)]=0.971−[3/5(−2/3log22/3−1/3log21/3)+2/5(0−log21)]结果我就不去计算了,每个特征都要遍历去计算它的信息增益,然后选取信息增益最大的最为最优特征进行分类,那么上面的代码也就很好理解了,它就是对信息增益的计算过程。
我们给出完整代码运行看结果:
from numpy import *
import operator
from math import log
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
def calcShannonEnt(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 splitDataSet(dataSet,axis,value):
retDataSet = []
for featVec in dataSet:
if featVec[axis] == value:
reducedFeatVec = featVec[:axis]
reducedFeatVec.extend(featVec[axis+1:])
retDataSet.append(reducedFeatVec)
return retDataSet
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
if __name__=='__main__':
myData,labels = createDataSet()
结果是:
运行结果告诉我们最好用第0个特征最为划分数据集的特征。
递归构建决策树:
我们学习了从数据集构造决策树算法所需要的子模块的功能,工作原理是:得到原始数据集,然后基于最好的属性值划分数据集,由于特征值可能多于两个,因此可能存在大于两个分支的数据集划分。第一次划分后,数据将被向下传递到树分支的下一个节点,在这个节点上,我们可以再一次划分数据。所以我们可以采用递归的原则处理数据集。这其实可以与我们数据结构中的树的概念结合理解,从根结点不断向下延伸扩展,加入子节点。
当然这里递归构造决策树的结束的条件是:程序遍历完所有的划分数据集的属性,或者每个分支下的所有实例都具有相同的分类。如果所有的实例都具有相同的分类,则得到一个叶子节点或终止块。任何到达叶子节点的数据必然属于叶子节点的分类。我们可以看图理解一下
程序要将No surfacing和Flippers这两个属性遍历,每个叶子结点的实例的最终的分类都是相同的,这就是递归的结束条件,每个数据去到它该去的位置。但是如果数据集已经处理了所有的属性,但是类标签依然不是唯一的,我们通常会采用多数表决的方法决定该叶子结点的分类,这意思就是如果叶子节点最终还是存在不是唯一类标签的实例,我们就以多数的那个实例的类标签作为该叶子结点的分类,所以我们可以编写代码:
def majorityCnt(classList):#统计classList中出现最多的类标签
classCount = {}#创建字典
for vote in classList:#遍历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]#返回出现次数最多的元素,即分类名称
我们接着创建决策树:
def createTree(dataSet,labels,featLabels):#创建决策树
#参数为数据集和分类属性标签列表
classList = [example[-1] for example in dataSet]
#将分类标签列提取并保存在列表中
if classList.count(classList[0]) == len(classList):
#列表的count统计指定元素的数量
#如果类别完全相同,停止划分,返回当前分类标签
return classList[0]
if len(dataSet[0]) == 1:
#遍历完所有的特征时返回出现次数最多的类标签
#递归创建树,后面会删除已经遍历过的特征
return majorityCnt(classList)
bestFeat = chooseBestFeatureToSplit(dataSet)
#选择数据集中的最优特征
bestFeatLabel = labels[bestFeat]
#获得最优特征的属性标签
featLabels.append(bestFeatLabel)
#保证原始列表不破坏
myTree = {bestFeatLabel:{}}
#根据最优特征的标签生成树
featValues = [example[bestFeat] for example in dataSet]
#获取数据集中所有的最优特征的属性值
uniqueVals = set(featValues)
#利用set删除重复的属性值
for value in uniqueVals:
del_bestFeat = bestFeat
del_labels = labels[bestFeat]
del(labels[bestFeat])
myTree[bestFeatLabel][value] = createTree(splitDataSet(dataSet,bestFeat,value),labels,featLabels)
#遍历特征,递归创建决策树
labels.insert(del_bestFeat,del_labels)
return myTree
#返回树
这里的代码与书中有点不同,添加了一个空列表featLabels,因为原来的属性标签列表每次找出最优的特征之后会将其删除,所以我们将删除的特征添加到原来列表,使之不会破坏。
在python中使用Matplotlib注解绘制树形图
字典创建的决策树它的表示的形式非常不易于理解,所以我们使用Matplotlib库来创建树形图,决策树的主要优点就是直观易于理解,如果不能将其直观地显示出来,那就无法发挥其优势。
Matplotlib库提供了一个注解的工具annotations,它可以在数据图形上添加文本注释,注解通常用于解释数据的内容,由于数据上面直接存在文本很丑陋,因此工具内嵌支持带箭头的划线工具,使我们可以在其他恰当位置指向数据的位置,并在此处添加描述的信息,解释数据的内容。
构造注解树
绘制一棵完整的树需要一些技巧,我们虽然有x,y的坐标,但是如何放置所有的树节点我们就需要知道有多少个叶节点,以便确定x轴的长度,我们还需要知道树的深度,有多少层,以便可以正确确定y轴的高度,所以我们写函数来获取决策树的叶节点数目和树的层数:
def getNumLeafs(myTree):#获取树的叶子节点数目
numLeafs = 0
#将叶子数初始化为0
firstStr = list(myTree.keys())[0]
#书中的代码对python3不行,python3返回的是dict_keys,
#不是返回列表信息
#获取第一个键值,就是第一个分类的特征值
secondDict = myTree[firstStr]
#获取下一组字典即字典对应的值,第一个分类结果
for key in secondDict.keys():
if type(secondDict[key]).__name__=='dict':
#测试该节点是否为字典类型,若不是,就说明它是叶子节点
numLeafs += getNumLeafs(secondDict[key])
#递归计算分支的叶节点数
else:#为叶子节点
numLeafs += 1
return numLeafs#返回结点数目
def getTreeDepth(myTree):#获取树的层数
maxDepth = 0
#初始化定义深度为0
firstrStr = list(myTree.keys())[0]
#获取第一个键值,就是第一个分类的特征值
secondDict = myTree[firstrStr]
#获取下一组字典即字典对应的值,第一个分类结果
for key in secondDict.keys():
#如果该节点不是字典类型,则为叶子节点
if type(secondDict[key]).__name__ == 'dict':
thisDepth = 1 + getTreeDepth(secondDict[key])
else:
thisDepth =1#递归计算层数
if thisDepth > maxDepth:#更新层数
maxDepth = thisDepth
return maxDepth#返回树的层数
确定了树的叶子结点和层数,我们可以绘制决策树:
def plotNode(nodeTxt,centerPt,parentPt,nodeType):#绘制节点
#参数为结点名,文本位置,标注的箭头位置,结点格式
arrow_args = dict(arrowstyle="<-")#定义箭头的格式
createPlot.ax1.annotate(nodeTxt,xy=parentPt,xycoords='axes fraction',\
xytext=centerPt,textcoords='axes fraction',va="center",ha="center",bbox=nodeType,arrowprops=arrow_args)
#绘制节点
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)
#matplotlib的text方法,给图形添加数据标签,添加的位置坐标以及
#标注的值
def plotTree(myTree,parentPt,nodeTxt):#绘制决策树
decisionNode = dict(boxstyle="sawtooth",fc="0.8")
#设置节点的格式
leafNode = dict(boxstyle="round4",fc="0.8")
#设置叶子节点的格式
numLeafs = getNumLeafs(myTree)
#获取树myTree的叶子结点数目(不是最初的树)
depth = getTreeDepth(myTree)
#获取树myTree的层数(不是最初的树)
firstStr = list(myTree.keys())[0]
#找到输入第一个元素属性
cntrPt = (plotTree.xoff + (1.0 + float(numLeafs))/2.0/plotTree.totalW,\
plotTree.yoff)
#计算当前节点的中心的位置,xoff和yoff追踪已经绘制的节点以及
#放置下一个节点的恰当位置,totalw为树的宽度,totalD为树的深度
plotMidText(cntrPt,parentPt,nodeTxt)
#添加标注信息
plotNode(firstStr,cntrPt,parentPt,decisionNode)
#绘制节点
secondDict = myTree[firstStr]
#获得下一个字典,继续绘制子节点
plotTree.yoff = plotTree.yoff - 1.0/plotTree.totalD
#y偏移,当前的位置减去将深度划分的一部分
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
#x偏移,计算横坐标
plotNode(secondDict[key],(plotTree.xoff,plotTree.yoff),cntrPt,leafNode)
plotMidText((plotTree.xoff,plotTree.yoff),cntrPt,str(key))
#绘制节点并标注信息
plotTree.yoff = plotTree.yoff + 1.0/plotTree.totalD
#增加y的偏移,计算纵坐标
def createPlot(inTree):#创建绘制面板
fig = plt.figure(1,facecolor='white')
#创建fig,绘制一个指定大小的画笔,定义背景的颜色为白色
fig.clf()
#figure清除所有的轴,但是窗口是打开的,可以重复使用
axprops = dict(xticks=[],yticks=[])
#定义横纵坐标
createPlot.ax1 = plt.subplot(111,frameon=False,**axprops)
#去掉下x,y轴,111表示图有1行1列,选取第一个图,frameon表示是否
#绘制坐标轴矩形
plotTree.totalW = float(getNumLeafs(inTree))
#获取决策树的节点数作为全局变量
plotTree.totalD = float(getTreeDepth(inTree))
#获取决策树的层数作为全局变量
plotTree.xoff = -0.5/plotTree.totalW
plotTree.yoff = 1.0#x偏移
plotTree(inTree,(0.5,1.0),'')
#绘制决策树,传入参数决策树,初始节点位置及标注信息
plt.show()
#显示图的结果
对于决策树的绘制其实也挺难理解的,并不是它的递归思路,而是一些计算结点坐标的处理,书中并没有做太多的解释,我查找资料(原文在这:博客地址)对它做一个大概的解释,我们定义的plotNode函数,这个函数是来绘制结点和箭头线的,就比如这个样子:
书中也提到,绘制的树并不会因为树的节点的增减和树的深度的增减而导致绘制出的图形出现问题,它使用图形的比例来绘制图形,一旦图形大小发生了变化,函数会自动按照图形大小重新绘制。
我们来做个简单介绍,这里利用整个树的叶子节点数目作为份数将x轴的长度进行平均分割,利用书的深度作为份数对y轴长度进行平均分割,利用plotTree.xoff作为全局变量,作为最近绘制的一个叶子节点的x的坐标,当再一次绘制叶子节点坐标的时候才会对plotTree.xoff进行改变,用plotTree.xoff作为当前绘制的深度,并且每次递归一层的时候就会减少一份,我们前面说到对x,y轴进行平均分割,其他时候利用这两个坐标点去计算非叶子节点,只要这两个参数确定,就可以确定一个点的坐标,也就可以绘制节点。也就是说整体的递归思路就可以是
1.绘制自身的节点
2.判断子节点为非叶子节点,递归
3.判断子节点为叶子节点,绘制节点
我们看看这张图来理解它的一些参数和处理意思,书中也提到x,y轴的总长度都是为1的,其中红色的方块就是非叶子节点的位置,我们可以理解为是属性标签,就比如No surfacing这类的节点,而对应的A就是叶子节点的位置,所以x轴的每一份就是每一个表格的长度就是为1/plotTree.totalW,这个是很好理解的,但是但是开始的时候,我们将plotTree.xoff定义为-0.5/plotTree.totalW,如下面的代码所示
plotTree.xoff = -0.5/plotTree.totalW
plotTree.yoff = 1.0#x偏移
也就是上图中,左边第一个表格的左边半个表格的位置,也就是那个竖的黑线的位置,这样做的好处是,以后确定其他的叶子节点的位置时,可以直接加整数倍的1/plotTree.totalW,这样都处于节点的中心位置。而我们是从根节点从上往下递归,所以plotTree.yoff定义为根节点的位置为1。
还有这段代码也许也很难理解,
cntrPt = (plotTree.xoff + (1.0 + float(numLeafs))/2.0/plotTree.totalW,
plotTree.yoff)
plotTree.xof为最近绘制的一个叶子节点的x的坐标,所以我们在确定当前节点位置时每次只需要确定当前节点有几个叶子节点,则其叶子节点所占的总距离就确定了为float(numLeafs)/plotTree.totalW1(总长为1),所以当前节点的位置即为其所有的叶子节点所占的距离的中间即一半为float(numLeafs)/2.0/plotTree.totalW1,但是因为开始的plotTree.xoff不是从0开始,而是左移半个表格,所以还需加上半个表格的距离为1/2/plotTree.totalW*1,则加起来便为 (1.0 + float(numLeafs))/2.0/plotTree.totalW,偏移量确定了,则再加上原来的plotTree.xoff即可。
plotTree(inTree,(0.5,1.0),’’)
对于赋给值为(0.5,1.0)是因为开始的根节点与当前节点的位置重合,利用上面的计算就是(0.5,1.0)。
测试和存储分类器
构造完决策树之后,我们可以将它用于实际的数据的分类。在执行数据分类时,需要决策树以及用于构造决策树的标签向量,然后,程序比较测试数据与决策树上的数值,递归执行该过程直到进入叶子节点,最后将测试数据定义为叶子节点所属的类型。我们可以写出分类函数:
def classify(inputTree,featLabels,testVec):#使用决策树分类
#参数是已经生成的决策树,存储选择的最优决策标签以及
#测试数据列表
firstStr = list(inputTree.keys())[0]
#获取决策树的节点属性
secondDict = inputTree[firstStr]
#获取下一个字典
featIndex = featLabels.index(firstStr)
#获得第一个特征对应的索引
#index方法查找当前列表中第一个匹配firstStr的元素索引
for key in secondDict.keys():#遍历树
if testVec[featIndex] == key:
#如果在secondDict[key]找到testVec[featIndex]
if type(secondDict[key]).__name__ == 'dict':
#判断类型是否是字典
classLabel = classify(secondDict[key],featLabels,testVec)
#如果是字典,递归寻找testVec
else:
classLabel = secondDict[key]
#若secondDict[key]是叶子节点,则将secondDict[key]赋给classLabel
return classLabel#返回类标签
所以我们可以用这段代码结合上面的代码运行,我们可以带入数据[1,0]和[1,1],我们可以看看预测结果:
这样的结果与书上的结果对比也是一致的。
使用算法:决策树的存储
构造决策树是很耗时的任务,即使是处理很小的数据集,如果数据集很大,将会耗费更多的计算时间,但是用创建好的决策树解决分类问题,则可以很快完成,所以为了节省计算时间,最好能够在每次执行分类的时候调用已经构造好的决策树。所以我们用python模块的pickle序列化对象,序列化对象可以在磁盘上保存,并在需要的时候读取出来。
所以我们使用pickle模块存储决策树:
def storeTree(inputTree,filename):#存储决策树
import pickle
#引入pickle库序列化对象
fw = open(filename,'wb')
#打开文件并写文件
pickle.dump(inputTree,fw)
#序列化对象,将创建好的决策树写入文件
fw.close()#关闭文件
def grabTree(filename):#读取决策树
import pickle
fr = open(filename,'rb')
#打开文件
return pickle.load(fr)
#反序列化对象,将文件中的数据解析成python对象
当然书上的代码应该是python2的代码,照着书上打会出现TypeError:must be str,not bytes的报错,所以我们需要将w改为wb,r改为rb。运行之后会在与代码文件同个目录下会出现一个文件:
使用决策树预测隐形眼镜类型
我们讲述一个例子解决决策树如何预测患者需要佩戴的隐形眼镜的类型,具体的数据下载我在上一篇博客中提到下载地址,这里不再强调。隐形眼镜的数据集是非常著名的数据集,它包含很多患者眼部状况的观察条件以及医生推荐的隐形眼镜类型。隐形眼镜类型包括硬材质,软材质以及不适合配合隐形眼镜。我们可以写如下代码来进行创建决策树和绘制决策树。
fr = open('lenses.txt')
lenses = [inst.strip().split('\t') for inst in fr.readlines()]
featLabels = []
lensesLabels = ['age','prescript','astigmatic','tearRate']
lensesTree = createTree(lenses,lensesLabels,featLabels)
print(lensesTree)
createPlot(lensesTree)
结果运行:
当然,决策树还可以用sklearn库进行,本篇博客也就不先介绍了,文章中出现的错误还望大家指出,也希望这篇博客对大家有所帮助。