机器学习实战之决策树

    花了差不多一个星期,终于把《机器学习实战》这本书的第三章的决策树过了一遍,对于一个python渣渣来说,确实是着实不容易,好多代码都得一个一个的去查,所以整体上进度比较慢,再加上中间开了一篇关于自然语言处理和产品知识库构建的文章,导致进度及其之慢,但是总体上对代码的理解足够深刻,知道了整体决策树中ID3的一个具体编法和流程,但是得有一定决策树得基础,有兴趣得可以去看看南京大学周志华的《机器学习》和李航的《统计学习方法》,废话不多说,看文章。

【一】计算数据信息熵

    这段代码主要是用于计算数据的每个特征信息熵,信息熵用于描述数据的混乱程度,信息熵越大说明数据包含的信息越多,也就是数据的波动越大。而ID3算法采用的是信息增益作为计算指标来评价每个特征所包含的信息的多少,而信息增益方法对可取数值较多的特征有所偏好,即本身可取数值较多的特征本身就包含更多的信息,为了减少这种偏好,又有C4.5和CART树,C4.5用信息增益率来衡量数据特征,从而摒除了这种偏好;CART树使用了“基尼指数”来衡量特征,基尼指数越小说明他的数据纯度就越高,选取特征时选取划分后使基尼指数最小的特征作为划分标准,即选取特征后使数据集变得更加纯,从而减少了数据集本身的混乱程度,本身决策树做的事情就是这个。

# -*- coding: utf-8 -*-
"""
Created on Thu Feb  1 19:47:56 2018

@author: chenxi
功能:机器学习实战之决策树
"""
from math import log
import operator
import numpy as np
import matplotlib.pyplot as plt
def calshannonent(dataset):   #计算信息熵
    numentries=len(dataset)
    labelcounts={}
    for featvec in dataset:               #对dataSet的每一个元素进行处理  
        currentlabel=featvec[-1]        #//将dataSet的每一个元素的最后一个元素选择出来        
        if currentlabel not in labelcounts.keys():  
            labelcounts[currentlabel]=0  #//若没有该键,则使用字典的自动添加进行添加值为0的项,取0是因为下一行代码              
        labelcounts[currentlabel] +=1    #对currentlabel计数,每有一个key:currentlabel,就在对应的key的值上加一
    shannonent=0
    for key in labelcounts:
        prob=float(labelcounts[key])/numentries
        shannonent -=prob*log(prob,2)
    return shannonent

样本集合D中第k类样本所占的比例pk,则信息熵的计算公式为:

式(1-1)
    假设离散属性(特征)a有V个可能的取值,若使用a来对样本数据基D进行划分,则就有V个分支节点,其中第v个取值包含的数据量为Dv,计算出 Dv的信息熵,给每个分支节点赋予Dv/D的权重,即样本分支节点的信息熵越大,它获得的信息增益越大。具体信息增益计算方法为:

(1-2)

【二】做数据集转换

    本文中我没有使用作者所提供的鱼的数据集,而是采用了UCI的机器学习数据集中的汽车评价数据集(http://archive.ics.uci.edu/ml/datasets/Car+Evaluation),里面有很多很好的数据集,建议做机器学习的同学多使用里面的数据。而UCI的数据集大部分都是txt格式的,所以写了段讲txt格式转换为矩阵的额外的代码。

def file2matrix(filename):
    fr=open(filename)
    lists=fr.readlines()
    listnum=[]
    for k in lists:
        listnum.append(k.strip().split(','))  
    return listnum

这段代码整体上比较简单,主要是用了readlines读取文本并且使用split对文本进行分割形成数据矩阵即可。

【三】划分数据集

     选取特征的方法是计算每个特征的信息增益,然后再基于信息增益的值来选取信息增益最大的特征作为下一次决策树分类的一句,这样保证了每次选取的特征都能最大化程度的减少数据集的信息混乱程度。选取特征之后需要根据该特征下所包含的可能的取值将数据集进行切分,就是举个例子说西瓜中颜色特征有“青绿”、“乌黑”等,我们根据特征的取值将其划分为“青绿类”的西瓜和“乌黑类”的西瓜。

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

抽取数据之后还牵扯到数据的添加,此段代码中用到了extend函数和append函数,都是做数据连接的,但是主要区别是append函数直接将连接的数据形成一个元素加入到列表中,而extend时将后段要加入的数据提取出其元素再加入到列表中去。举个栗子就是:

kk=['1','2','3']

bb=['4','5','6']

kk.append(bb)

kk
Out[40]: ['1', '2', '3', ['4', '5', '6']]

aa=['1','2','3']

bb=['4','5','6']

aa.extend(bb)

aa
Out[44]: ['1', '2', '3', '4', '5', '6']

【四】选取数据集特征进行数据划分

         选取特征的方法是计算每个特征的信息增益值,然后找到具有信息增益值最大的特征作为划分的依据进行数据集划分,具体代码为:
def choosebestfeaturetosplit(dataset):   #就算出信息增益之后选取信息增益值最高的特征作为下一次分类的标准
    numfeatures=len(dataset[0])-1     #计算特征数量,列表【0】表示列的数量,-1是减去最后的类别特征
    baseentropy=calshannonent(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*calshannonent(subdataset)#计算该特征下每一个取值的子数据集的信息熵
        infogain=baseentropy-newentropy   #计算每个特征的信息增益
      #  print("第%d个特征是的取值是%s,对应的信息增益值是%f"%((i+1),uniquevals,infogain))
        if(infogain>bestinfogain):
            bestinfogain=infogain
            bestfeature=i
   # print("第%d个特征的信息增益最大,所以选择它作为划分的依据,其特征的取值为%s,对应的信息增益值是%f"%((i+1),uniquevals,infogain))
    return bestfeature

由于大部分代码都添加了注释,文中就不再细述,这里讲讲set函数,set函数主要是识别列表或元组中所有具有的特征的取值,同样的元素算一个,并且set返回的是一个字典,举个栗子:

扫描二维码关注公众号,回复: 3195368 查看本文章
a=['1', '2', '3', '3', '2', '1']
set(a)
Out[49]: {'1', '2', '3'}

【五】寻找具有最大类别数量的叶节点

    决策树构建结束的标准是该分支下面所有的数据都具有相同的分类,如果所有数据都具有相同的分类,则得到的叶子结点或者终止块,任何到达这个叶子结点的必属于这个分类,上代码。

def majoritycnt(classlist):#针对所有特征都用完,但是最后一个特征中类别还是存在很大差异,比如西瓜颜色为青绿的情况下同时存在好瓜和坏瓜,无法进行划分,此时选取该类别中最多的类
#作为划分的返回值,majoritycnt的作用就是找到类别最多的一个作为返回值
    classcount={}#创建字典
    for vote in classlist:
        if vote not in classcount.keys():
            classcount[vote]=0   #如果现阶段的字典中缺少这一类的特征,创建到字典中并令其值为0
            classcount[vote] +=1 #循环一次,在对应的字典索引vote的数量上加一
        sortedclasscount=sorted(classcount.items(),key=operator.itemgetter(1),reverse=True)#operator.itemgetter(1)是抓取其中第2个数据的值
        #利用sorted方法对class count进行排序,并且以key=operator.itemgetter(1)作为排序依据降序排序因为用了(reverse=True),3.0以上的版本不再有iteritems而是items
        return sortedclasscount[0][0]

    分类过程中不可能到最后分支之后所有的数据集都属于同一类,因为存在噪声数据,所以我们是找到分类结束时某个类别最多的类别作为该分支的叶节点的取值,上述代码就是找到数量最多的那一类作为叶节点的取值,其中用到了operator中的items函数,3.x以上的版本不再有iteritems而是items,而《机器学习实战》代码时基于2.x的版本的代码,所以经常会报错,建议大家写代码时边写边思考,从而找到一些不一样的东西,回到正题,items() 方法是把dict对象转换成了包含tuple的list,栗子:

>>> d = { 'Adam': 95, 'Lisa': 85, 'Bart': 59 }
>>> print d.items()
[('Lisa', 85), ('Adam', 95), ('Bart', 59)]

【六】递归构建决策树

    构建决策树用到的最基本的思想是递归,而递归结束的条件有两个:(1)在该分支下所有的类标签都相同,即在该支叶节点下所有的样本都属于同一类;(2)使用了所有的特征,但是样本数据还是存在一定的分歧,这个时候就要使用上诉的majoritycnt(classlist)函数了。

    在树的构建过程中,是每一次利用choosebestfeaturetosplit(dataset)函数找到在剩下的数据集中信息增益最大的特征作为分类的依据,然后每次生成的tree是 mytree={bestfeatlabel:{}}这样的形式,保证了每次递归都在字典中存在子字典,从而最后完成决策树的构建。同时在构建过程中使用过的标签需要进行删除,从而不影响下一次特征的选取,同样也用到了set()函数。
def createtree(dataset,labels):
    classlist=[example[-1] for example in dataset]   #提取dataset中的最后一栏——种类标签
    if classlist.count(classlist[0])==len(classlist): #计算classlist[0]出现的次数,如果相等,说明都是属于一类,不用继续往下划分
        return classlist[0]
    if len(dataset[0])==1:   #看还剩下多少个属性,如果只有一个属性,但是类别标签又多个,就直接用majoritycnt方法进行整理  选取类别最多的作为返回值
        return majoritycnt(classlist)
    bestfeat=choosebestfeaturetosplit(dataset)#选取信息增益最大的特征作为下一次分类的依据
    bestfeatlabel=labels[bestfeat]   #选取特征对应的标签
    mytree={bestfeatlabel:{}}   #创建tree字典,紧跟现阶段最优特征,下一个特征位于第二个大括号内,循环递归
    del(labels[bestfeat])   #使用过的特征从中删除
    featvalues=[example[bestfeat] for example in dataset]  #特征值对应的该栏数据
    uniquevals=set(featvalues)   #找到featvalues所包含的所有元素,同名元素算一个
    for value in uniquevals:
        sublabels=labels[:]  #子标签的意思是循环一次之后会从中删除用过的标签 ,剩下的就是子标签了
        mytree[bestfeatlabel][value]=createtree(splitdataset(dataset,bestfeat,value),sublabels)   #循环递归生成树
    return mytree

【七】使用matplotlib对生成的树进行注释

    此过程中主要用到的是matplotlib中的annotate包,具体有很多方法和函数,我也只是过了一下书上的东西,有兴趣继续深入的可以参考https://www.jianshu.com/p/1411c51194de

在此罗列一部分方法
  1. # 添加注释  
  2. # 第一个参数是注释的内容  
  3. # xy设置箭头尖的坐标  
  4. # xytext设置注释内容显示的起始位置  
  5. # arrowprops 用来设置箭头  
  6. # facecolor 设置箭头的颜色  
  7. # headlength 箭头的头的长度  
  8. # headwidth 箭头的宽度  
  9. # width 箭身的宽度  
  10. plt.annotate(u"This is a zhushi", xy = (0, 1), xytext = (-4, 50),\  
  11.              arrowprops = dict(facecolor = "r", headlength = 10, headwidth = 30, width = 20))  
  12. # 可以通过设置xy和xytext中坐标的值来设置箭身是否倾斜 

#使用文本注解绘制树节点
#包含了边框的类型,边框线的粗细等
decisionnode=dict(boxstyle="sawtooth",fc="0.8",pad=1)# boxstyle为文本框的类型,sawtooth是锯齿形,fc是边框线粗细  ,pad指的是外边框锯齿形(圆形等)的大小
leafnode=dict(boxstyle="round4",fc="0.8",pad=1)# 定义决策树的叶子结点的描述属性 round4表示圆形
arrow_args=dict(arrowstyle="<-")#定义箭头属性

def plotnode(nodetxt,centerpt,parentpt,nodetype):
     # annotate是关于一个数据点的文本  
    # nodeTxt为要显示的文本,centerPt为文本的中心点,箭头所在的点,parentPt为指向文本的点  
    #annotate的作用是添加注释,nodetxt是注释的内容,
    #nodetype指的是输入的节点(边框)的形状
    createplot.ax1.annotate(nodetxt,xy=parentpt,xycoords='axes fraction',\
                           xytext=centerpt,textcoords='axes fraction',\
                           va="center",ha="center",bbox=nodetype,arrowprops=arrow_args)
    
遇到的问题是

中文边框转码之后不显示


英文边框之后自适应显示



【八】计算树的叶节点数目和树的层数

    getnumleafs()函数和gettreedepth()函数在结构和方法上都比较类似,因为tree字典中的每一个大括号的第一个字符都是它对应的键值,所以我们只需要判断第二个还是不是键值,如果是的话说明还有树或者深度,都是用了递归的思想,在每次循环中都先找到该字典中的第一个字符,然后判断在该键值下的第二个字符是不是还是键,是的话说明还有深度或者层数,不是的话说明已经找完了。其中遇到了这样的问题:
(1)一开始写这句的时候if type(seconddict[key]).__name__=='dict':书上的是.-name-(单个下划线),但是实际上是双下划线,再者当不加.__name__得时候type函数获得的是字典得类型,而不是本身就是字典,所以要加上name表示这个名字叫做“dict”。

(2)2.x的版本中是firststr=mytree.keys()[],但是dict_keys型的数据不支持索引,所以强制转换成list即可,即 firststr=list(mytree.keys())[0]。

def getnumleafs(mytree):#计算叶子节点的个数(不包括中间的分支节点)
    numleafs=0
  #原代码  firststr=mytree.keys()[0]  # 获得myTree的第一个键值,即第一个特征,分割的标签 
    firststr=list(mytree.keys())[0]
    #遇到的问题是mytree.keys()获得的类型是dict_keys,而dict_keys不支持索引,我的解决办法是把获得的dict_keys强制转化为list即可
    seconddict=mytree[firststr]# 根据键值得到对应的值,即根据第一个特征分类的结果  
    for key in seconddict.keys():  #获取第二个小字典中的key
        if type(seconddict[key]).__name__=='dict':#判断是否小字典中是否还包含新的字典(即新的分支)   书上写的是.-name-但是3.0以后得版本都应该写成.__name__(两个下划线)
            numleafs +=getnumleafs(seconddict[key])#包含的话进行递归从而继续循环获得新的分支所包含的叶节点的数量
        else: numleafs +=1#不包含的话就停止迭代并把现在的小字典加一表示这边有一个分支
    return numleafs


def gettreedepth(mytree):#计算判断节点的个数
    maxdepth=0
    firststr=list(mytree.keys())[0]
    seconddict=mytree[firststr]
    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

【九】生成决策树图形

这段代码相对简单,就不再细细的解释了,大家自己去看书吧,下面是一些重要的内容:

xOff

xOff和yOff用来记录当前要画的叶子结点的位置。
画布的范围x轴和y轴都是0到1,我们希望所有的叶子结点平均分布在x轴上。totalW记录叶子结点的个数,那么 1/totalW 正好是每个叶子结点的宽度
如果叶子结点的坐标是 1/totalW , 2/totalW, 3/totalW, …, 1 的话,就正好在宽度的最右边,为了让坐标在宽度的中间,需要减去0.5 / totalW 。所以createPlot函数中,初始化 plotTree.xOff 的值为-0.5/plotTree.totalW。这样每次 xOff + 1/totalW ,正好是下1个结点的准确位置

yOff
yOff的初始值为1,每向下递归一次,这个值减去 1 / totalD

cntrPt

cntrPt用来记录当前要画的树的树根的结点位置

在plotTree函数中,它是这样计算的
cntrPt = (plotTree.xOff + (1.0 + float(numLeafs))/2.0/plotTree.totalW, plotTree.yOff)
numLeafs记录当前的树中叶子结点个数。我们希望树根在这些所有叶子节点的中间。
plotTree.xOff + (1.0 + float(numLeafs))/2.0/plotTree.totalW
这里的 1.0 + numLeafs 需要拆开来理解,也就是
plotTree.xOff +  float(numLeafs)/2.0/plotTree.totalW +1.0/2.0/plotTree.totalW
plotTree.xOff +  1/2 * float(numLeafs)/plotTree.totalW + 0.5/plotTree.totalW
因为xOff的初始值是-0.5/plotTree.totalW ,是往左偏了0.5/plotTree.tatalW 的,这里正好加回去。这样cntrPt记录的x坐标正好是所有叶子结点的中心点

#plottree.totalw和plottree.totald是全局变量  
'''    


def plotmidtext(cntrpt,parentpt,txtstring):#作用是计算tree的中间位置    cntrpt起始位置,parentpt终止位置,txtstring:文本标签信息
    xmid=(parentpt[0]-cntrpt[0])/2.0+cntrpt[0]# cntrPt 起点坐标 子节点坐标   parentPt 结束坐标 父节点坐标
    ymid=(parentpt[1]-cntrpt[1])/2.0+cntrpt[1]#找到x和y的中间位置
    createplot.ax1.text(xmid,ymid,txtstring)
    
    
def plottree(mytree,parentpt,nodetxt):
    numleafs=getnumleafs(mytree)
    depth=gettreedepth(mytree)
    firststr=list(mytree.keys())[0]
    cntrpt=(plottree.xoff+(1.0+float(numleafs))/2.0/plottree.totalw,plottree.yoff)#计算子节点的坐标 
    plotmidtext(cntrpt,parentpt,nodetxt) #绘制线上的文字  
    plotnode(firststr,cntrpt,parentpt,decisionnode)#绘制节点  
    seconddict=mytree[firststr]
    plottree.yoff=plottree.yoff-1.0/plottree.totald#每绘制一次图,将y的坐标减少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
            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

    
def createplot(intree):
     # 类似于Matlab的figure,定义一个画布(暂且这么称呼吧),背景为白色 
    fig=plt.figure(1,facecolor='white')
    fig.clf()    # 把画布清空 
    axprops=dict(xticks=[],yticks=[])   
    # createPlot.ax1为全局变量,绘制图像的句柄,subplot为定义了一个绘图,111表示figure中的图有1行1列,即1个,最后的1代表第一个图 
    # frameon表示是否绘制坐标轴矩形 
    createplot.ax1=plt.subplot(111,frameon=False,**axprops) 
    
    plottree.totalw=float(getnumleafs(intree))
    plottree.totald=float(gettreedepth(intree))
    plottree.xoff=-0.6/plottree.totalw;plottree.yoff=1.2;
    plottree(intree,(0.5,1.0),'')
    plt.show()



但是对于数据较大的决策树来说坐标1并不能满足其大小的要求,如图为用uci汽车评价数据画出来的图:


很大所以基本上1不能满足,大家可以根据树的大小调整画布的大小解决问题。

【十】生成树之后利用测试数据测试代码

        构建生成树之后需要利用测试数据测试代码的分类效果,具体代码如下:
#测试和存储分类器

def classify(inputtree,featlabels,testvec):
    classlabel=''
    firststr=list(inputtree.keys())[0]
    seconddict=inputtree[firststr]
    featindex=featlabels.index(firststr)
    for key in seconddict.keys():
        if testvec[featindex]==key:
            if type(seconddict[key]).__name__=='dict':
                classlabel=classify(seconddict[key],featlabels,testvec)
            else:classlabel=seconddict[key] 
    return classlabel

    代码也是使用了递归的思想,即每次都找到测试数据在每个特征下的值,根据值选取合适的分支,到了下一个分支再进行一次循环直到到了决策树的叶节点为止,从而将测试数据归类到对应的分类之下。

运行过程中会报错如下:


ValueError: 'no surfacing' is not in list
主要原因是我先运行 mytree=createtree(data1,labels1)函数,而createtree函数在运行过程中回删除标签中已经用过的值,所以导致之后labels不完整,解决方法是再新建一个即可。

【十一】编写对于多数据的测试代码

    这段代码是书上没有,主要是用于有多个数据集时的测试,相对简单,就是每次提取出测试数据中的一组测试数据用于代码的测试,并且最后计算出分类正确的个数和正确率。

#测试决策树分类性能'the calculte result is%s,the true result is %s'%(result,testdata[i][-1]) 
def testefficiency(inputtree,labels,testdata):
    flag=0
    for i in range(len(testdata)): 
        result=classify(inputtree,labels,testdata[i][:6])
        if(result==testdata[i][-1]): flag +=1
        print('the calculte result is%s,the true result is %s'%(result,testdata[i][-1]))
    print('the data number is %d,but the right number is %d'%(len(testdata),flag))

下图是计算结果中我截的图,计算正确的个数为74,而数据总数为127个,正确率只比50%略高一些,说明代码可能有些问题或者是决策树需要剪枝,在后期我再分析代码找问题。

【十二】存储与读取决策树

    存储与读取决策树主要是用到了pickle模块,具体我也没有细细的去深究pickle模块,具体可以参考http://www.php.cn/python-tutorials-372984.html。

运行过程中报错如下:

fr=open(filename)
return pickle.load(fr)
但是报错UnicodeDecodeError: 'gbk' codec can't decode byte 0x80 in position 0: illegal multibyte sequence
这是因为早期的pickle代码是二进制的pickle(除了最早的版本外)是二进制格式的,所以你应该带 'rb' 标志打开文件。
改成
def grabtree(filename):
import pickle
fr=open(filename,'rb')
return pickle.load(fr)即可

 #生成决策树的存储              
def storetree(inputtree,filename):
    import pickle
    fw=open(filename,'wb')
    pickle.dump(inputtree,fw)
    fw.close
def grabtree(filename):
    import pickle
    fr=open(filename,'rb')
    return pickle.load(fr)

【十三】存在的问题


(1)对matplotlib中的annotate的理解还不够深,只知道可是使用不同的边框和箭头来进行注释,并且边框大小一般是自适应的,不适用于中文,要不然回出现乱码的情况,箭头的格式和大小都可调
(2)对pickle框架的理解不够深,只知道wb和rb的作用等
(3)决策树中会出现过度匹配问题,导致数据量较大时会出现分类未空的情况,就比如之前所使用的uci car data分类正确率大概只有50%,其他情况都是分类之后返回了空值,无法使用等情况。

猜你喜欢

转载自blog.csdn.net/cxjoker/article/details/79501887