Loading

2-决策树

🌳 决策树 Decision Tree


1. 决策树的定义

一个叫做 "二十个问题" 的游戏,游戏的规则很简单: 参与游戏的一方在脑海中想某个事物,其他参与者向他提问,只允许提 20 个问题,问题的答案也只能用对或错回答。问问题的人通过推断分解,逐步缩小待猜测事物的范围,最后得到游戏的答案。

决策树的工作原理与这 二十个问题 相似,用户输入一系列的数据,然后给出游戏的答案。

决策树(Decision Tree)算法是一种基本的分类与回归方法,是最经常使用的数据挖掘算法之一。我们这章节只讨论用于分类的决策树。

决策树模型呈树形结构,在分类问题中,表示基于特征对实例进行分类的过程。它可以认为是 if-then 规则的集合,也可以认为是定义在特征空间与类空间上的条件概率分布。如下的流程图就是一个决策树(邮件分类系统):

正方形代表判断模块 decision block,椭圆形代表终止模块 terminating block,表示已得出结论,终止运行。从判断模块引出的左右箭头称为分支 branch

首先检测发送邮件域名地址。如果地址为 myEmployer.com, 则将其放在分类 "无聊时需要阅读的邮件"中。如果邮件不是来自这个域名,则检测邮件内容里是否包含单词 "曲棍球" , 如果包含则将邮件归类到 "需要及时处理的朋友邮件"。如果不包含则将邮件归类到 "无需阅读的垃圾邮件" 。

🚩 由此,引出决策树的定义如下:

分类决策树模型是一种描述对实例进行分类的树形结构。决策树由结点(node)和有向边(directed edge)组成。结点有两种类型: 内部结点(internal node)和叶结点(leaf node)。内部结点表示一个特征或属性(features),叶结点表示一个类(labels)。

用决策树对需要测试的实例进行分类: 从根节点开始,对实例的某一特征进行测试,根据测试结果,将实例分配到其子结点;这时,每一个子结点对应着该特征的一个取值。如此递归地对实例进行测试并分配,直至达到叶结点。最后将实例分配到叶结点的类中。

2. 决策树的构造

决策树算法特点

  • 优点: 计算复杂度不高,输出结果易于理解,数据有缺失也能跑,可以处理不相关特征。
  • 缺点: 容易过拟合。
  • 适用数据类型: 数值型和标称型。

在构造决策树时,我们需要解决的第一个问题就是,当前数据集上哪个特征在划分数据时起决定性作用,为此,我们必须评估每个特征。完成评估后,数据集就被划分成几个数据子集。这些数据子集会分布在第一个决策点的所有分支上。如果某个分支上的数据属于同一类型,则已经正确分类,无须继续进行分割,否则,则需要重复划分数据子集的过程,直到所有相同类型的数据都在一个数据子集内

创建分支的伪代码函数 createBranch 如下:

def createBranch():
    '''递归函数'''
    检测数据集中的所有数据的分类标签是否相同:
        If so return 类标签
        Else:
            寻找划分数据集的最好特征(划分之后信息熵最小,也就是信息增益最大的特征)
            划分数据集
            创建分支节点
                for 每个划分的子集
                    调用函数 createBranch (创建分支的函数)并增加返回结果到分支节点中
            return 分支节点

决策树的一般流程

  • 收集数据: 可以使用任何方法。
  • 准备数据: 树构造算法 (这里使用的是ID3算法,只适用于标称型数据,这就是为什么数值型数据必须离散化。 还有其他的树构造算法,比如CART)
  • 分析数据: 可以使用任何方法,构造树完成之后,我们应该检查图形是否符合预期。
  • 训练算法: 构造树的数据结构。
  • 测试算法: 使用训练好的树计算错误率。
  • 使用算法: 此步骤可以适用于任何监督学习任务,而使用决策树可以更好地理解数据的内在含义。

① 信息增益 information gain

下表的数据包含 5 个海洋动物,将这些动物分成两类:鱼类 和非鱼类。

现在我们决定依据第一个特征还是第二个特征进行划分数据。在回答这个问题之前,我们必须用量化的方法判断如何划分数据。

划分数据的大原则就是:将无序的数据变得更加有序。

在划分数据集前后信息发生的变化称为信息增益 information gain,计算每个特征值划分数据集获得的信息增益,获得信息增益最高的特征就是最好的选择。

集合信息的度量方式称为香农熵或者熵 entropy,熵定义为信息的期望值。⭐ 信息增益 = 原始熵 - 新的熵。在清除这个概念之前,我们必须知道信息的定义:

如果待分类的事务可能划分在多个分类中,则符号 $x_i$ 的信息定义为:,其中 $p(x_i)$ 是选择该分类的概率。

为了计算熵,我们需要计算所有类别所有可能值包含的信息期望值,其中 n 是分类的数目。

计算熵的 Python 代码如下:

from math import log

def calcShannonEnt(dataSet):
    numEntries = len(dataSet) # 求list的长度,表示计算参与训练的数据量
    labelCounts = {} # 计算分类标签label出现的次数
    for featVec in dataSet: #the the number of unique elements and their occurance
        # 将当前实例的标签存储,即每一行数据的最后一个数据代表的是标签
        currentLabel = featVec[-1]
        # 为所有可能的分类创建字典,如果当前的键值不存在,则扩展字典并将当前键值加入字典。每个键值都记录了当前类别出现的次数。
        if currentLabel not in labelCounts.keys(): labelCounts[currentLabel] = 0
        labelCounts[currentLabel] += 1
        
    # 对于 label 标签的占比,求出 label 标签的香农熵
    shannonEnt = 0.0
    for key in labelCounts:
        prob = float(labelCounts[key])/numEntries # 使用所有类标签的发生频率计算类别出现的概率。
        shannonEnt -= prob * log(prob, 2) # 计算香农熵,以 2 为底求对数
    return shannonEnt

下面我们来看看如何利用熵来划分数据集。

首先,利用 createDataSet 函数创建上表 3-1 的数据集:

def createDataSet():
    dataSet = [[1, 1, 'yes'],
               [1, 1, 'yes'],
               [1, 0, 'no'],
               [0, 1, 'no'],
               [0, 1, 'no']]
    labels = ['no surfacing', 'flippers']
    #change to discrete values
    return dataSet, labels

熵越高,混合的数据越多,我们可以在数据集中添加更多的分类,观察熵是如何变化的。这里我们修改第一行数据的分类为 maybe,测试熵的变化:

得到熵之后,我们就可以按照获取最大信息增益的方法划分数据集。下一节我们将具体学习如何划分数据集以及和度量信息增益。

② 划分数据集

上节我们学习了如何度量数据集的无序程度,分类算法除了需要测量信息熵,还需要划分数据集,度量花费数据集的熵,以便判断当前是否正确的划分了数据集。我们将对每个特征划分数据集的结果计算一次信息熵,然后判断按照哪个特征划分数据集是最好的划分方式。

按照给定特征划分数据集:👇

'''
splitDataSet(依据第axis列进行分类,如果第axis列的数据等于 value,就将该行数据划分到我们创建的新的数据集中

Args:
    dataSet 数据集                    待划分的数据集
    axis 表示数据集的第axis列         划分数据集的特征
    value 表示第axis列对应的value值   需要返回的特征的值
Returns:
    axis列为value的数据集【该数据集需要排除第axis列】
'''
def splitDataSet(dataSet, axis, value):
    retDataSet = []
    # 判断axis列的值是否为value
    for featVec in dataSet:
        if featVec[axis] == value:
            reduceFeatVec  = featVec[:axis] # 前 axis 列,比如 axis = 2,就是前 2 列
            reduceFeatVec.extend(featVec[axis+1:]) # 这两句的意思就是去除第 axis 列
            retDataSet.append(reduceFeatVec) # 将第 axis 列的值 = value 的数据行添加进结果集(且该结果集去除了 axis 列)
    return retDataSet

💡 上述代码中用到了 Python 自带的 extend 和 append 方法,两个方法功能相似,但是在处理多个列表的时候,处理结果截然不同。

music_media = []
music_media.extend([1,2,3])
print music_media # [1, 2, 3]
  • 使用 append 的时候,是将 object 看作一个对象,整体打包添加到 music_media 对象中。

    music_media.append([4,5,6])
    print music_media #[1, 2, 3, [4, 5, 6]]
    
  • 使用 extend 的时候,是将 sequence 看作一个序列,将这个序列和 music_media 序列合并,并放在其后面。

    music_media.extend([7,8,9])
    print music_media # [1, 2, 3, 7, 8, 9]
    

OK,接下来我们将遍历整个数据集,循环计算香农熵和 splitDataSet() 函数,找到最好的特征划分方式。最小的熵就是划分数据的最好方式。

📑 选择最好的数据集划分方式 Python 代码如下:

# 选择最好的特征
def chooseBestFeatureToSplit(dataSet):
    '''
    Args:
        dataSet 数据集
    Returns:
        bestFeature 最优的特征列索引
    '''
    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) # 使用 set 对 list 进行去重,获得去重后的集合
        newEntropy = 0.0 # 创建一个临时的信息熵
        for value in uniqueVals: # 遍历某一列的 value 集合,计算该特征列的信息熵
            # 遍历当前特征中的所有唯一属性值,对每个唯一属性值划分一次数据集,计算数据集的新熵值,并对所有唯一特征值得到的熵求和。
            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

代码运行结果告诉我们,第 0 列或者说第 0 个特征是最好的用于划分数据集的特征,分割数据集后,熵分别如下:

③ 递归构建决策树

目前我们已经学习了从数据集构造决策树算法所需要的子功能模块,其工作原理如下:

首先基于最好的属性值划分数据集,由于特征值可能多于两个,因此 可能存在大于两个分支的数据集划分。第一次划分之后,数据将被向下传递到树分支的下一个结点,在这个节点上,我们可以再次划分数据,因此我们采用递归的原则处理数据集。递归结束的条件是程序遍历完所有数据或者每个分支下的实例具有相同的分类。

如果数据集已经处理了所有属性,但是同一子集中的类标签依然不是唯一的,此时我们可以采用表决的方法(即选取出现次数最多的标签)决定该叶子结点的所属类别 ,代码如下:

import operator
# 查询子集中出现次数最多的标签
def majorityCnt(classList):
    classCount={} # 字典对象,存储 classList 中每个类标签与出现的频率
    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] # 返回出现次数最多的标签(分类名称)

⭐ 创建决策树的代码如下:

# 递归创建决策树 
def createTree(dataSet, labels):
    '''
    dataSet: 数据集
    labels: 数据集中的所有标签列表(算法本身并不需要这个变量,但是为了给出数据明确的含义,我们将他作为一个输入参数提供)
    
    最优列 最优特征 最优标签,都是一个意思
    '''
    classList = [example[-1] for example in dataSet] # 数据集的最后一列就是标签
    
    # 第一个停止条件: 所有的类标签完全相同,则直接返回该类标签。
    if classList.count(classList[0]) == len(classList): # 如果标签列表的第一个值出现的次数=整个集合的数量,也就说只有一个类别,就只直接返回结果就行
        return classList[0]
    # 第二个停止条件: 使用完了所有特征,仍然不能将数据集划分成仅包含唯一类别的分组。
    if len(dataSet[0]) == 1: # 如果数据集只有1列,那么出现label次数最多的一类,作为结果
        return majorityCnt(classList)
    
    bestFeat = chooseBestFeatureToSplit(dataSet) # 选择最优的特征列下标
    bestFeatLabel = labels[bestFeat] # 获取最优特征名称
    
    myTree = {bestFeatLabel:{}} # 初始化myTree  字典变量
    
    del(labels[bestFeat]) # 删除原有标签集 labels 中的最优标签
    
    featValues = [example[bestFeat] for example in dataSet] # 从数据集中取出最优列对应的数据
    uniqueVals = set(featValues) # 将最优列对应的数据集合去重
    
    # 递归构造决策树
    for value in uniqueVals:
        subLabels = labels[:] # copy all of labels, so trees don't mess up existing labels.注意,此时标签集已经不包含挑选出的最优标签
        myTree[bestFeatLabel][value] = createTree(splitDataSet(dataSet, bestFeat, value), subLabels)
    return myTree

第一个关键字 no surfacing 是第一个划分 数据集的特征名称,该关键字的值也是另一个数据字典。后面的关键字都是 no surfacing 的结点的子结点,这些值可能是类标签,也可能是数据字典,如果是标签,则说明该结点是叶子结点;如果是字典,则说明该结点是判断结点。

比如这颗决策树就包含了 3 个叶子结点以及 2 个判断结点,示意图如下:

本节讲述了如何正确的构造决策树,下一节将介绍如何绘制图形。

3. 在 Python 中使用 Matplotlib 绘制树形图

① Matplotlib 注解 annotations

Matplotlib 提供了一个注解工具 annotations,可以在数据图形上添加文本注释。

📑 使用文本注解绘制树结点示例代码如下:

import matplotlib.pyplot as plt

# 定义文本框(结点)和箭头格式
decisionNode = dict(boxstyle="sawtooth", fc="0.8")
leafNode = dict(boxstyle="round4", fc="0.8")
arrow_args = dict(arrowstyle="<-")

# 绘制带箭头的注解
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')
   fig.clf()
   createPlot.ax1 = plt.subplot(111, frameon=False) #ticks for demo puropses
   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()

② 构造注解树

构造一颗完整的树,我们需要知道树的层次(确定 y 轴的高度),叶节点的数目(确定 x 轴的长度)

# 获取叶节点数目
def getNumLeafs(myTree):
    numLeafs  = 0 # 存储叶节点数目
    firstStr = list(myTree)[0] # 决策树的第一个结点,即第一次划分数据集的标签
    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 # 存储树的层数
    firstStr = list(myTree)[0]
    secondDict = myTree[firstStr]
    for key in secondDict.keys():
        if type(secondDict[key]).__name__ == 'dict':# 测试结点的数据类型是否为字典,如果是则该节点是决策结点,层数+1并继续递归
            thisDepth = 1 + getTreeDepth(secondDict[key]) # 遍历到叶子结点,从递归中返回并将层数 + 1
        else: thisDepth = 1
        if thisDepth > maxDepth: maxDepth = thisDepth
    return maxDepth

测试一下,假如有 2 棵决策树:

def retrieveTree(i):
    listOfTrees = [{'no surfacing': {0: 'no', 1: 'yes'}}, # 第 1 棵树
                   {'no surfacing': {0: 'no', 1: {'flippers': {0: {'head': {0: 'no', 1: 'yes'}}, 1: 'no'}}}} # 第 2 棵树
                  ]
    return listOfTrees[i] # 返回第 i 棵树

绘制一棵完整的树(注意,前文已经定义了 createPlot 函数,此处需要更新这部分代码)

这部分代码没有仔细研究,日后有时间再来 🤝

def plotMidText(cntrPt, parentPt, txtString):
    xMid = (parentPt[0]-cntrPt[0])/2.0 + cntrPt[0]
    yMid = (parentPt[1]-cntrPt[1])/2.0 + cntrPt[1]
    createPlot.ax1.text(xMid, yMid, txtString, va="center", ha="center", rotation=30)

def plotTree(myTree, parentPt, nodeTxt):#if the first key tells you what feat was split on
    numLeafs = getNumLeafs(myTree)  #this determines the x width of this tree
    depth = getTreeDepth(myTree)
    firstStr = list(myTree)[0]     #the text label for this node should be this
    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
    for key in secondDict.keys():
        if type(secondDict[key]).__name__ == 'dict':#test to see if the nodes are dictonaires, if not they are leaf nodes
            plotTree(secondDict[key], cntrPt, str(key))        #recursion
        else:   #it's a leaf node print the leaf node
            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
#if you do get a dictonary you know it's a tree, and the first element will be another dict

def createPlot(inTree):
    fig = plt.figure(1, facecolor='white')
    fig.clf()
    axprops = dict(xticks=[], yticks=[])
    createPlot.ax1 = plt.subplot(111, frameon=False, **axprops)    #no ticks
    #createPlot.ax1 = plt.subplot(111, frameon=False) #ticks for demo puropses
    plotTree.totalW = float(getNumLeafs(inTree))
    plotTree.totalD = float(getTreeDepth(inTree))
    plotTree.xOff = -0.5/plotTree.totalW; plotTree.yOff = 1.0
    plotTree(inTree, (0.5, 1.0), '')
    plt.show()

4. 测试和存储分类器

① 测试算法:使用决策树执行分类

本节我们将使用决策树构建分类器。

# 使用决策树的分类函数
def classify(inputTree, featLabels, testVec):
    """classify(给输入的节点,进行分类)

    Args:
        inputTree  决策树模型
        featLabels 标签对应的名称
        testVec    测试输入的数据
    Returns:
        classLabel 分类标签
    """
    firstStr = list(inputTree)[0] # 获取tree的根节点对于的key值
    secondDict = inputTree[firstStr] # 通过key得到根节点对应的value(字典)
    featIndex = featLabels.index(firstStr)  # 使用 index 方法查找当前列表的第一个匹配 firstStr 变量的元素。这样就知道输入的testVec怎么对照树来做分类
    key = testVec[featIndex] # 测试数据,找到根节点对应的label位置,也就知道从输入的数据的第几位来开始分类
    valueOfFeat =  secondDict[key] # 标签key对应的value(标签值)
    if isinstance(valueOfFeat,dict): # 判断分支是否结束
        classLabel = classify(valueOfFeat, featLabels, testVec)
    else:
        classLabel = valueOfFeat
    return  classLabel

决策树 myTree 应该是由 createTree 决策树构造函数构建出来的,此处为了直观显示,就直接定义了~ 🤭

② 使用算法:决策树的存储

构造决策树是很耗时的任务,即使处理很小的数据集。为了节省计算时间,最好能够每次执行分类时调用已经构造好的决策树,可以使用 Python 模块 pickle 序列化对象。序列化的对象可以在磁盘上保存对象,并在需要的时候读取出来。任何对象都可以执行序列化操作,字典对象也不例外。

# 使用 pickle 模块存储决策树
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)

👍 通过上面的代码,可以将分类器存储在磁盘上,而不用每次都对数据分类时重新学习一遍。像上一章介绍的 kNN 算法就无法持久化分类器。

5. 示例:使用决策树预测隐形眼镜类型

本节我们将通过一个例子来讲解决策树如何预测患者需要佩戴的隐形眼镜类型。

隐形眼镜数据集如下图,包含很多患者眼部状况的观察条件以及医生推荐的隐形眼镜类型,隐形眼镜类型包括硬材质、软材质以及不适合佩戴隐形眼镜:

myope:近视、hyper:高度近视

no lenses:不适合佩戴隐形眼镜、hard:硬材质、soft:软材质

第一列年龄 age,第二列近视程度 prescript,第三列是否散光 astigmatic,第四列撕裂率 tearRate,第五列需要佩戴的隐形眼镜类型。

fr = open('lenses.txt')
lenses = [inst.strip().split('\t') for inst in fr.readlines()] # 以 '\t'(tab)分割数据
lensesLabels = ['age', 'prescript', 'astigmatic','tearRate']
lensesTree = createTree(lenses,lensesLabels) # 构造决策树

上图的决策树非常好地匹配了实验数据,然而这些匹配选项太多了,过拟合。为了减少过拟合问题,我们可以裁剪决策树,去掉一些不必要的叶子节点。如果叶子节点只能增加少许信息,则可以删除该叶子节点,将它并入其他叶子节点中。

树回归章节我们将讨论决策树构造算法 CART ,本章使用的算法称为 ID3,它是一个好的算法但并不完美。如果存在太多的特征划分,ID3 算法会面临很多的问题。

6. 小结

到目前为止,我们学习了 kNN 算法和 ID3 决策树构造算法,都是结果确定的分类算法,数据实例会明确划分到某个类中,下一章我们讨论的分类算法将不能完全确定数据实例应该划分到某个分类,或者说只能给出分类的概率。

📚 References

posted @ 2023-01-10 17:51  RuoVea  阅读(29)  评论(0编辑  收藏  举报