决策树

Posted 小丫头い

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了决策树相关的知识,希望对你有一定的参考价值。

前言:本系列文章旨在熟悉算法的同时增强编程能力,使用的都是很小的数据集,代码是自己一点点码出来的,如有错误还望指正,O(∩_∩)O谢谢

数据集

  • 这是一个非常著名的隐形眼镜的数据集,总共有四个属性age, prescript, astigmatic, tearRate,最后一个是列标签hard:硬材质,soft:软材质,no lenses:不适合佩戴。很显然这个数据集是让我们根据各个属性来分类这个人适合佩戴什么材质的眼镜或者干脆不能佩戴。
f = open("D:\\\\code\\\\Spyder\\\\lenses.txt")
lenses = [inst.strip().split('\\t') for inst in f.readlines()]
print lenses 
[['young', 'myope', 'no', 'reduced', 'no lenses']
['young', 'myope', 'no', 'normal', 'soft']
['young', 'myope', 'yes', 'reduced', 'no lenses']
['young', 'myope', 'yes', 'normal', 'hard']
['young', 'hyper', 'no', 'reduced', 'no lenses']
['young', 'hyper', 'no', 'normal', 'soft']
['young', 'hyper', 'yes', 'reduced', 'no lenses']
['young', 'hyper', 'yes', 'normal', 'hard']
['pre', 'myope', 'no', 'reduced', 'no lenses']
['pre', 'myope', 'no', 'normal', 'soft']
['pre', 'myope', 'yes', 'reduced', 'no lenses']
['pre', 'myope', 'yes', 'normal', 'hard']
['pre', 'hyper', 'no', 'reduced', 'no lenses']
['pre', 'hyper', 'no', 'normal', 'soft']
['pre', 'hyper', 'yes', 'reduced', 'no lenses']
['pre', 'hyper', 'yes', 'normal', 'no lenses']
['presbyopic', 'myope', 'no', 'reduced', 'no lenses']
['presbyopic', 'myope', 'no', 'normal', 'no lenses']
['presbyopic', 'myope', 'yes', 'reduced', 'no lenses']
['presbyopic', 'myope', 'yes', 'normal', 'hard']
['presbyopic', 'hyper', 'no', 'reduced', 'no lenses']
['presbyopic', 'hyper', 'no', 'normal', 'soft']
['presbyopic', 'hyper', 'yes', 'reduced', 'no lenses']
['presbyopic', 'hyper', 'yes', 'normal', 'no lenses']]

计算数据集的信息熵

  • 我们知道决策树中用来衡量划分效果的指标就是信息熵,当然也就基尼系数等等,这里简单起见只讨论信息熵,那么先不管三七二十一写好这个方法即可。显然方法的参数只需要输入数据集即可:
from math import log
def calEnt(dataSet):
    dataNum = len(dataSet)
    labelCount = 
    for data in dataSet:        
        labelCount[data[-1]] = labelCount.get(data[-1],0)+1    
    entropy = 0
    for key in labelCount.keys():
        p = float(labelCount[key])/dataNum
        entropy -= p*log(p,2)
    return entropy

划分数据集

  • 给定一个数据集,给定一个划分属性,假设这个划分属性的取值为m,那么我们可以将数据集划分为m块。
  • 这里需要说明下,返回值是一个字典数据,字典的键是这个属性的各种取值,字典的值是属性为这个取值的子数据集列表。不能单纯的只返回划分的若干子集列表,因为后面在进行分类时需要根据属性取值找到他所属的分支。我开始没写对,后面分类时才修正。
def splitDataSet(dataSet, selectedAttri):  
    splitDataSet = 
    for data in dataSet:
        if(data[selectedAttri] not in splitDataSet):
            splitDataSet[data[selectedAttri]] = [data]
        else:
            splitDataSet[data[selectedAttri]].append(data)
    return splitDataSet

选择最好的属性来划分数据集

  • 对于给定数据集dataSet以及给定的候选属性索引列表,我们可以遍历所有的属性计算以其作为分裂属性的信息增益,选择引起信息增益最大的那个属性索引为返回值,这里我为了简化计算,计算的是分裂后的信息熵,显然分裂后信息熵最小,那么信息增益当然是最大的,因为它们对应着同一个基数据集熵。这是ID3算法的原理,稍微修改下这个函数,换成使用信息增益比就是C4.5,换成基尼系数就是CART,所以把方法分开写很容易扩展。
import sys
def chooseBestAttri(dataSet, candidateAttri):
    minEnt = sys.maxint 
    minAtt = -1
    for attri in candidateAttri:
        splitedDataSet = splitDataSet(dataSet, attri) #对每个属性求其划分子集        
        splitEnt = 0
        for split in splitedDataSet:            
            p = float(len(splitedDataSet[split]))/len(dataSet) #注意这里转为float,我开始忽视了,导致都是0
            splitEnt += p*calEnt(splitedDataSet[split])
        if(splitEnt<minEnt): #原本是求信息增益最大——>求划分后的信息熵最小
            minEnt = splitEnt 
            minAtt = attri
    return minAtt

多数表决决定该节点类别

  • 在生成决策树的过程中,停止条件有两种:
    • 当前子集的类标签都相同时,此时不需要再进行划分,那么此时将该类标签设置为当前节点的label.
    • 当前的候选属性列表为空,没有分裂属性了只能停止长树,此时将子集中类标签出现次数最多的设置为当前节点的label.
  • 当然不是只能使用多数表决来决定当前label,也可以使用概率的形式,那么最后进行分类时就可以说这个样本有多大概率属于A有多大概率属于B酱紫。
def majorityCnt(dataSet):
    classCount = 
    for data in dataSet:
        classCount[data[-1]] = classCount.get(data[-1],0) + 1
    return max(classCount,key=classCount.get)

递归生成决策树

  • 在生成决策树之前,我们需要定义一种数据结构也就是树的节点,那么这棵树必须包含以下属性:
    • sons:字典数据,键为当前属性划分的各种取值,值为子节点对象,m种取值就产生m个划分,这个属性相当于二叉树的left,right左右子节点,只是这里我们是m叉树,且m大小不一所以用字典存储,一方面还存储了属性值;
    • attri:当前节点的划分属性索引;
    • isLeaf:标示当前节点是否为叶子节点;
    • label:当前节点的类标签,若该节点为叶子节点时才有意义;
class TreeNode(object):
    def __init__(self, sons=None, val=None, attri=-1, isLeaf=False, label=None):
        self.sons = sons
        self.attri = attri
        self.isLeaf = isLeaf
        self.label = label
  • 递归构造树,熟练使用递归后对于这种结果写起来果然顺手了,本科时写得很困难啊( ⊙ o ⊙ )
def createTree(dataSet, candidateAttri):
    if(len(dataSet)==0):
        return None
    '''如果候选属性为空或者当前子集为同一类样本,则该节点为叶节点'''
    labels = set([data[-1] for data in dataSet]) #获取dataSet的所有类标签取值,用来判断是否是纯净子集
    if(len(candidateAttri)==0 or len(labels)==1):
        return TreeNode(isLeaf=True, label=majorityCnt(dataSet))    
    bestAttri = chooseBestAttri(dataSet, candidateAttri) #选出最好的分裂属性
    splitedDataSet = splitDataSet(dataSet, bestAttri) #获取划分子集  
    sons =  #存储子节点
    '''对每一个子集递归调用createTree然后将返回节点添加到当前节点的sons字典中'''    
    for split in splitedDataSet:
        index = candidateAttri.index(bestAttri)
        sons[split] = createTree(splitedDataSet[split], candidateAttri[:index]+candidateAttri[index+1:])        
    return TreeNode(sons, attri=bestAttri, isLeaf=False, label=None)

层序打印决策树

  • 我将生成的树打印出来了,与正确的树是一模一样的哟;这个代码需要用点技巧,因为需要打印树的层数:
def printTree(root, depth):   
    print "当前节点为根节点,高度为"+str(depth)+",分裂属性索引为:"+str(root.attri)    
    depth+=1   
    attriVal = root.sons.keys()
    nextNodes = root.sons.values()
    count = len(nextNodes)
    while(len(nextNodes)!=0):
        tmp = nextNodes.pop(0) # 不指定参数默认删除最后一个元素
        if(tmp.isLeaf==True):
            print "当前节点为叶子节点,高度为"+str(depth)+",所属分支属性值为"+str(attriVal.pop(0))+",类标签为:"+str(tmp.label)
        else:
            print "当前节点为分裂节点,高度为"+str(depth)+",所属分支属性值为"+str(attriVal.pop(0))+",分裂属性索引为:"+str(tmp.attri)
        count-=1
        if(count==0):
            depth+=1
            if(tmp.sons!=None):
                count = len(tmp.sons)
        if(tmp.sons!=None):
            attriVal.extend(tmp.sons.keys())
            nextNodes.extend(tmp.sons.values())

训练

lensesTree = createTree(lenses, range(len(lenses[0])-1))
printTree(root, 1) 
  • 打印的结果如下:根据这个描述完全可以把树画出来
当前节点为根节点,高度为1,分裂属性索引为:3
当前节点为叶子节点,高度为2,所属分支属性值为reduced,类标签为:no lenses
当前节点为分裂节点,高度为2,所属分支属性值为normal,分裂属性索引为:2
当前节点为分裂节点,高度为3,所属分支属性值为yes,分裂属性索引为:1
当前节点为分裂节点,高度为3,所属分支属性值为no,分裂属性索引为:0
当前节点为分裂节点,高度为4,所属分支属性值为hyper,分裂属性索引为:0
当前节点为叶子节点,高度为4,所属分支属性值为myope,类标签为:hard
当前节点为叶子节点,高度为4,所属分支属性值为pre,类标签为:soft
当前节点为分裂节点,高度为5,所属分支属性值为presbyopic,分裂属性索引为:1
当前节点为叶子节点,高度为5,所属分支属性值为young,类标签为:soft
当前节点为叶子节点,高度为5,所属分支属性值为pre,类标签为:no lenses
当前节点为叶子节点,高度为5,所属分支属性值为presbyopic,类标签为:no lenses
当前节点为叶子节点,高度为5,所属分支属性值为young,类标签为:hard
当前节点为叶子节点,高度为5,所属分支属性值为hyper,类标签为:soft
当前节点为叶子节点,高度为5,所属分支属性值为myope,类标签为:no lenses

使用决策树执行分类

def classify(root, testX):
    labelX = None
    for key in root.sons:
        if(testX[root.attri]==key):
            if(root.sons[key].isLeaf==True):
                labelX = root.sons[key].label
                break
            labelX = classify(root.sons[key], testX)
    return labelX
print classify(root, ['presbyopic', 'hyper', 'yes', 'normal', 'no lenses'])
'''
no lenses
'''

保存模型

  • 有的时候我们的模型训练好了,可以将模型参数保存起来,而不用总是常驻内存或者在测试时重新训练一遍,保存的方式如下:
import pickle
fw = open('tree.txt','w')
pickle.dump(root,fw)
fw.close()
  • 当需要用时,也就是用来分类新数据时,可以重建这棵树:
fr = open("tree.txt")
newRoot= pickle.load(fr)

总结

  • 写代码的总结:在前一篇写k-近邻算法时,我采用的方式是先合后分,先写出了classify方法,那是因为这个方法是lazy的,只有在测试的时候才进行建模,并且这个算法相对较为简单,依据伪代码很快就可以写出来,然后里面相应的方法先用函数名代替,知道它的参数和返回值是什么即可,最后补全即可。
    但是这一篇决策树我采取的是先分后合,把自己能想到的每个模块都写出来,然后聚合成一个功能很强大的学习算法,因为决策树较为复杂,这样将功能分离出来写的好处就是可以修改若干方法,整个模型就不一样了,解耦带来了很大的便利性。之后我应该会一直采取这种方式。
  • 算法总结:写的这个ID3算法很显然只能处理数值型数据,尽管可以通过量化的方法将数值型数据转化为标称型数据,但是如果存在太多的特征划分依旧会带来对多取值特征的偏好,所以后面的C4.5采用的是信息增益比。
    另一方面我发现我生成的树严重过拟合了,因为有几个叶子节点中的数据样本只有一个,因此如果这个数据是噪声那么这棵树就拟合了噪声,泛化性能很低。因此需要裁剪决策树。
    • 决策树优点:计算复杂度不高,输出结果易于理解,对中间值的缺失不敏感,可以处理不相关特征数据;
    • 决策树的缺点:可能会产生过度匹配的问题;
    • 适用数据类型:数值型和标称型;

以上是关于决策树的主要内容,如果未能解决你的问题,请参考以下文章

决策树之CART算法

机器学习:决策树(基尼系数)

在决策树类相关算法中,一个接点的基尼系数通常是大于还是小于他的父节点?是总是大于还是总是小于?

决策树

决策树算法 CART和C4.5决策树有啥区别?各用于啥领域?

R语言中自编基尼系数的CART回归决策树的实现