关于《机器学习实战》中创建决策树的核心代码分析
SIAT nyk 2017年10月21日星期六
一、源码内容
def createTree(dataSet,labels):
classList = [example[-1] for example in dataSet]
ifclassList.count(classList[0]) == len(classList):
return classList[0]#stop splitting when all of the classes are equal
iflen(dataSet[0]) == 1: #stop splitting when there are no more features indataSet
return majorityCnt(classList)
bestFeat = chooseBestFeatureToSplit(dataSet)
bestFeatLabel = labels[bestFeat]
myTree = {bestFeatLabel:{}}
del(labels[bestFeat])
featValues = [example[bestFeat] for example in dataSet]
uniqueVals = set(featValues)
forvalue in uniqueVals:
subLabels = labels[:] #copy all of labels, so trees don't mess upexisting labels
myTree[bestFeatLabel][value] = createTree(splitDataSet(dataSet,bestFeat, value),subLabels)
return myTree
原始数据集(训练用)如下:
dataSet = [[1, 1, 'yes'],
[1, 1, 'yes'],
[1, 0, 'no'],
[0, 1, 'no'],
[0, 1, 'no']]
labels = ['nosurfacing','flippers']
dataSet只有属性的值和类标签的值,不知道属性代表的含义,比较抽象,因此对每一个属性附加标签说明,那么有以下的训练集
(属性标签1)no surfacing | (属性标签2)flippers | (类标签) Fish(在决策树描述中用不上) |
1 | 1 | yes |
1 | 1 | yes |
1 | 0 | no |
0 | 1 | no |
0 | 1 | no |
这本书最大的优点就是实践性非常强,书本所有的算法不管多高深都给用目前世界上在深度学习(机器学习)领域第一受欢迎的Python语言实现,最大的缺点就是很少讲算法背后的数学理论,比如要弄懂条件熵,就要懂,离散随机变量,随机事件,二维离散随机变量,离散随机变量的分布律,经典概率事件,条件概率的求法。数学的期望定义等等。书本中实际上采用ID3算法构建决策树,ID3算法就是一种通过信息伦里面的信息增益的方法来选择最佳特征然后划分训练集的递归过程(不懂的请参考:http://blog.youkuaiyun.com/xwd18280820053/article/details/70739368),需要注意以下公式的内含:
需要知道原始数据集的信息熵,然后每个特征属性所有取值对应的条件熵,原信息熵减去条件熵所得到的差值就是该特征值所对应的信息增益,计算完所有的属性特征的信息增益,选一个最大的,那么该特征属性就是最优的划分属性,然后以它为划分基准,该特征属性有多少种取值,就有多少种子数据集的划分,然后再划分的数据集当中,有些是非常纯的,有些是不纯的。在觉得构建决策树过程中,它是一个递归的过程,对于纯的子训练集,那么就把他归到叶子节点,对于不纯的子训练集,则继续按照上面的求最大信息增益法选择最优特征然后进一步划分。直到纯或者人为干涉停止为止(什么情况下需要人为干涉让其停止呢,简单来说,已经是针对最后一个特征划分了,但是类标签属性依然不纯,比如上面的鱼类这个,按最后一个最优特征,无论哪个特征的取值,对应的子集中的类标签都是有yes 和no 混合在一块,这时候就是要人为的停止构建树了,一般是按“多数服从少数”的规则。即,yes多的就取yes。No多的就取no。
针对《机器学习实战》里面用递归方式和嵌套字典构建决策树的代码,对于一般新手,真的不太好理解,最笨最直接的方式就是“代码跟踪,单步分析”的方式
下面针对creaTree这个函数,现在采用以上方式一步一步的进入去分析:
递归调用比较复杂,但是简单来说,过程是这样:外层代码进入creaTree一次,在外层没有退出之前,通过for循环,让内层代码多次递归调用creaTree,当内层递归调用完毕,外层的creaTree也就退出了,也就返回最终的决策树。所谓内层代码和外层代码在实现上都是一样的,都是creaTree的代码,只是为了区分先后执行顺序和让分析的逻辑更清晰人为的拆分。
好了,让我们开始调用creaTree,那么原始的输入参数为:
原始的数据集为:
dataSet = [[1, 1, 'yes'],
[1, 1, 'yes'],
[1, 0, 'no'],
[0, 1, 'no'],
[0, 1, 'no']]
labels = ['nosurfacing','flippers']
特别注意:针对第一次调用creaTree,除了for循环里面的creaTree函数,所有的代码都是外层代码OUT1。而for循环中的creaTree函数就是相对外层代码OUT1的内层代码IN1。针对IN1内部的for中的creaTree,IN1又是他们的外层代码,依次类推。
好了,开始分析代码:classList =[example[-1] for example in dataSet]
这段代码的作用就是从dataSet数据集当中采用Python列表推导的方式提取最后一列(类属性)的每一个取值,然后构建一个列表,执行后的结果:
classList=[‘yes’,’yes’,’no’,’no’,’no’]。
ifclassList.count(classList[0]) == len(classList):
return classList[0]#stop splitting whenall of the classes are equal
以上的代码的作用是:提取出来的类属性的取值都是一样的话,那么说明这个子训练集是纯的,没必要划分下去了,立马停止划分,直接返回结果可以作为叶子节点。它统计classList当中跟第一个取值相同的所有元素的个数(包括第一个)),(实际上统计其他下标的值的个数也一样)看看是否等于classlis这个list的长度,如果是,就是纯的。第一次执行,毫无疑问肯定不是。因为针对dataset这个数据集
iflen(dataSet[0]) == 1: #stop splitting when there are no more features indataSet
return majorityCnt(classList)
通过统计传递进来的训练数据集(第一次调用就是原始的,后面的都是划分的子数据集),的特征属性个数,看看是不是没有了,特征值属性占的列都没有了,那么dataset的长度就相当于只有类标签那一列,取值为1,第一次调用createTree,毫无疑问,lend(dataset[0])为3,,满足不了。
bestFeat =chooseBestFeatureToSplit(dataSet)
调用选取最优特征值的函数后,返回最优特征值的所在列的标号,这个函数的具体内容在前面章节有,核心就是前面提到的计算信息熵和条件信息熵进而获得信息增益的过程,第一次执行,结果为0,也就是第0列的特征。那就是第一次以no surfacing这个属性标签的取值来划分数据集。因为取值有两种,那么划分的子数据集也对应有两个。
no surfacing=1
(属性标签1)no surfacing | (属性标签2)flippers | (类标签) Fish(在决策树描述中用不上) |
1 | 1 | yes |
1 | 1 | yes |
1 | 0 | no |
no surfacing=0
(属性标签1)no surfacing | (属性标签2)flippers | (类标签) Fish(在决策树描述中用不上) |
0 | 1 | no |
0 | 1 | no |
经过上述代码的执行bestFeat=0,
bestFeatLabel = labels[bestFeat]#执行取出0这一列对应的属性标签nosurfacing
myTree ={bestFeatLabel:{}} #初始化myTree这个用嵌套字典构成的树
那么myTree = { ‘nosurfacing’:{}}
del(labels[bestFeat]) #从属性标签中除去本次已经作为最有特征对应的标签。
执行后,labels =['flippers']
featValues =[example[bestFeat] for example in dataSet]
uniqueVals = set(featValues)
上面两句的意思是提取出当次选择的最优特征属性(第一次进入选的话是no surfacing)的取值,取值总类虽然有两种,但是个数不只两个,因此采用集合的方式去重。让列表里面的取值唯一。注意:uniqueVals的数据类型是集合(里面不允许有重复的数据)。
uniqueVals=set([0,1])
for value inuniqueVals:
subLabels = labels[:] #copy all of labels, so trees don't messup existing labels
myTree[bestFeatLabel][value] =createTree(splitDataSet(dataSet, bestFeat, value),subLabels)
上面的代码恐怕是这个函数当中最核心最复杂的的部分,简单来书,针对最优选择出来的属性标签的每一个取值,划分成不同的数据集,对每一个数据集又递归调用createTree(dataSet,labels)这个函数进行进一步划分数据集(还没退出最外层的createTree的情况下),此时的labels就是同一个特征值不同取值对应的子集了。
(1)
回到上面的表格:
当:no surfacing=0
表1
(属性标签1)no surfacing | (属性标签2)flippers | (类标签) Fish(在决策树描述中用不上) |
0 | 1 | no |
0 | 1 | no |
以上划分的数据集作为新的labels传递到createTree,上面的表格如何划分的呢,那就通过splitDataSet(dataSet, bestFeat, value),对于value=no_surfacing=0而言,splitDataSet返回的子集是纯的。那么在for中调用createTree,createTree返回的结果为no,(符合createTree中前面的第一个if的判断条件),也就是说:nosurfacing=0,只需要调用一次createTree就可以获得纯的结果作为终止条件(叶子节点)。
(2)no surfacing=1,
通过调用splitDataSet(dataSet,bestFeat, value),返回后得到的训练数据子集如下:
表2
(属性标签1)no surfacing | (属性标签2)flippers | (类标签) Fish(在决策树描述中用不上) |
1 | 1 | yes |
1 | 1 | yes |
1 | 0 | no |
此时可以看到,no surfacing这个最优特征值的取值为1的情况下,划分的子集,并不纯,还需要继续的通过递归调用createTree划分。
在递归调用中,下面来详细看它的每一次调用的结果和调用过程中里面的关键变量的变化。
(a) 递归调用的第一次
首先看传递的参数,labels就是表2的子集。Labels就是labels =['flippers'],执行createTree(dataSet,labels),再次进入函数体分析:
通过表2,两个if不符合条件,bestFeat毫无疑问为1(就剩一个属性了,不是它是谁),那么bestFeatLabel为'flippers'。
执行myTree ={bestFeatLabel:{}}后
myTree ={'flippers':{}}
执行完以下代码后:
del(labels[bestFeat])
featValues = [example[bestFeat] for examplein dataSet]
uniqueVals = set(featValues)
'flippers'的这个特征的每一个取值就可以提取出来。
依然还是uniqueVals=set([0,1])
好了,又进入了匪夷所思的for递归了,此时
(1) uniqueVals=0,对应'flippers'取0,那么划分的子集为;
表3
(属性标签2)flippers | (类标签) Fish(在决策树描述中用不上) |
0 | no |
针对uniqueVals=0,createTree(dataSet,labels)返回的结果为纯的,为’no’,(符合第一个if条件,注意以上的dataset就是表3对应的子集了。,labels就是'flippers‘),因为这个子集的类属性的取值都一样,纯了,不需要继续划分,结束递归。作为叶子点返回。返回结果为’no’
(2) uniqueVals=1,对应'flippers'取1,那么划分的子集为;
(属性标签2)flippers | (类标签) Fish(在决策树描述中用不上) |
1 | yes |
1 | yes |
同样调用createTree(dataSet,labels)后, 返回结果为’yes’。也不需要继续划分,作为叶子节点返回。
经过上述的分布分析,下面开始从外层到内层的逐渐循环说明最终的决策树结果的生成过程。
(1) 最外层执行
还没进入递归调用createTree之前,myTree={‘no surfacing’:{}}
(2) 第一内层执行
,其中(1)中的{}里面的内容由
no surfacing=0,和no surfacing=1的对应的createTree递归调用的返回值填充。而这些就是内层执行的问题了。
当no surfacing=0,createTree的返回值为’no’,所以
myTree={‘no surfacing’:{0:’no’}},怎么得来的呢,请看源码:myTree[bestFeatLabel][value] =createTree(splitDataSet(dataSet, bestFeat, value),subLabels)
此时bestFeatLabel=‘no surfacing’, value=0,则代码可翻译为:
myTree[‘no surfacing’l][0]=‘no’,#将’no’这个字符串,赋给‘no surfacing’这个键的下面的嵌套键0对应的值。如果原来没有嵌套键0和0对应的健值,那么就新建(比如本例),如果有就是以新值替换原来的值。在这个构建决策树过程中,嵌套键值都是从无到有,从外到内,并没有替换的过程。
当no surfacing=1,createTree的执行比上面复杂,需要再此调用两次createTree才能返回(因为no surfacing=1划分的并不纯,还需要继续选择最优特征划分)。那么,相对于后面的两次createTree,no surfacing=1时,createTree的还没有进入for中的时候就是外层执行的代码。no surfacing=1时,执行createTree,还没进入for的递归调用前。createTree中的myTree={‘flippers’:{ }},同样,{ }的内容跟flippers=0,flippers=1有关,
当flippers=0,调用的createTree返回值为’no’,当flippers=1,调用的createTree返回值为’yes’,最终myTree={‘flippers’:{ 0:’no’,1:’yes’}}
返回到最外层,当no surfacing=1,createTree的返回结果为{‘flippers’:{ 0:’no’,1:’yes’}}
因此myTree[‘no surfacing’l][1]= {‘flippers’:{ 0:’no’,1:’yes’}}
那么最外层代码退出后,myTree= myTree={‘nosurfacing’:{0:’no’,1: {‘flippers’:{ 0:’no’,1:’yes’}}}}