决策树之CART算法

决策树的另一种实现,即CART算法。又叫做分类回归树。CART 决策树是基于基尼指数来选择划分属性,基尼指数可以来度量数据集的纯度。基尼指数越小,数据集的纯度就越高,最终选择基尼指数最小的属性作为最优划分属性。本文整理了自己的一些见解和一位大神还有steve_99的blog.

 

Contents

   1. CART算法的认识

   2. CART算法的原理

   3. CART算法的实现

1. CART算法的认识

   Classification And Regression Tree,即分类回归树算法,简称CART算法,它是决策树的一种实现,通

   常决策树主要有三种实现,分别是ID3算法,CART算法和C4.5算法。

   CART算法是一种二分递归分割技术,把当前样本划分为两个子样本,使得生成的每个非叶子结点都有两个分支,

   因此CART算法生成的决策树是结构简洁的二叉树。由于CART算法构成的是一个二叉树,它在每一步的决策时只能

   是“是”或者“否”,即使一个feature有多个取值,也是把数据分为两部分。在CART算法中主要分为两个步骤

   (1)将样本递归划分进行建树过程

   (2)用验证数据进行剪枝

2. CART算法的原理

   上面说到了CART算法分为两个过程,其中第一个过程进行递归建立二叉树,那么它是如何进行划分的 ?

   设代表单个样本的个属性,表示所属类别。CART算法通过递归的方式将维的空间划分为不重

   叠的矩形。划分步骤大致如下

   (1)选一个自变量,再选取的一个值维空间划分为两部分,一部分的所有点都满足

       另一部分的所有点都满足,对非连续变量来说属性值的取值只有两个,即等于该值或不等于该值。

   (2)递归处理,将上面得到的两部分按步骤(1)重新选取一个属性继续划分,直到把整个维空间都划分完。

   在划分时候有一个问题,它是按照什么标准来划分的 ? 对于一个变量属性来说,它的划分点是一对连续变量属

   性值的中点。假设个样本的集合一个属性有个连续的值,那么则会有个分裂点,每个分裂点为相邻

   两个连续值的均值。每个属性的划分按照能减少的杂质的量来进行排序,而杂质的减少量定义为划分前的杂质减

   去划分后的每个节点的杂质量划分所占比率之和。而杂质度量方法常用Gini指标,假设一个样本共有类,那么

   一个节点的Gini不纯度可定义为

          

 

   其中表示属于类的概率,当Gini(A)=0时,所有样本属于同类,所有类在节点中以等概率出现时,Gini(A)

   最大化,此时

   有了上述理论基础,实际的递归划分过程是这样的:如果当前节点的所有样本都不属于同一类或者只剩下一个样

   本,那么此节点为非叶子节点,所以会尝试样本的每个属性以及每个属性对应的分裂点,尝试找到杂质变量最大

   的一个划分,该属性划分的子树即为最优分支。

   下面举个简单的例子,如下图

   

 

   在上述图中,属性有3个,分别是有房情况,婚姻状况和年收入,其中有房情况和婚姻状况是离散的取值,而年

   收入是连续的取值。拖欠贷款者属于分类的结果。

   假设现在来看有房情况这个属性,那么按照它划分后的Gini指数计算如下

   

 

   而对于婚姻状况属性来说,它的取值有3种,按照每种属性值分裂后Gini指标计算如下

    

 

   最后还有一个取值连续的属性,年收入,它的取值是连续的,那么连续的取值采用分裂点进行分裂。如下

    

 

   根据这样的分裂规则CART算法就能完成建树过程。

   建树完成后就进行第二步了,即根据验证数据进行剪枝。在CART树的建树过程中,可能存在Overfitting,许多

   分支中反映的是数据中的异常,这样的决策树对分类的准确性不高,那么需要检测并减去这些不可靠的分支。决策

   树常用的剪枝有预剪枝和后剪枝,CART算法采用后剪枝,具体方法为代价复杂性剪枝法。可参考如下链接

   剪枝参考:http://www.cnblogs.com/zhangchaoyang/articles/2709922.html 

3. CART算法的实现(西瓜书第四章习题4.4)

编程实现基于基尼指数进行划分的决策树算法,为表4.2中数据生成预剪枝,后剪枝决策树,并与为剪枝决策树进行比较。

表4.2如下:

编号,色泽,根蒂,敲声,纹理,脐部,触感,好瓜
1,青绿,蜷缩,浊响,清晰,凹陷,硬滑,是
2,乌黑,蜷缩,沉闷,清晰,凹陷,硬滑,是
3,乌黑,蜷缩,浊响,清晰,凹陷,硬滑,是
6,青绿,稍蜷,浊响,清晰,稍凹,软粘,是
7,乌黑,稍蜷,浊响,稍糊,稍凹,软粘,是
10,青绿,硬挺,清脆,清晰,平坦,软粘,否
14,浅白,稍蜷,沉闷,稍糊,凹陷,硬滑,否
15,乌黑,稍蜷,浊响,清晰,稍凹,软粘,否
16,浅白,蜷缩,浊响,模糊,平坦,硬滑,否
17,青绿,蜷缩,沉闷,稍糊,稍凹,硬滑,否
4,青绿,蜷缩,沉闷,清晰,凹陷,硬滑,是
-------------------------------------
5,浅白,蜷缩,浊响,清晰,凹陷,硬滑,是
8,乌黑,稍蜷,浊响,清晰,稍凹,硬滑,是
9,乌黑,稍蜷,沉闷,稍糊,稍凹,硬滑,否
11,浅白,硬挺,清脆,模糊,平坦,硬滑,否
12,浅白,蜷缩,浊响,模糊,平坦,软粘,否
13,青绿,稍蜷,浊响,稍糊,凹陷,硬滑,否

本文所使用的训练集是{1,2,3,6,7,10,14,15,16,17,4},验证集是{4,5,8,9,11,12,13}。

因为表格4.2是去掉了数值型的数据,而题4.3使用的是表4.3的数据,为了也能适用数值型数据,并没有将这部分内容去除,事实上这些代码是不会被执行的,每次会根据判断的结果的数据类型进行计算。 
未剪枝、预剪枝和后剪枝对应只需要修改一下主函数中的mode参数即可, 
分别为 unpro:未剪枝 prev:预剪枝 post:后剪枝

如果让决策树完全伸展的话,训练误差最终到0,但是会带来很严重的过拟合,学习到一些不该学到的东西。解决这个问题的方法之一就是对决策树进行剪枝。 剪枝分为预剪枝和后剪枝。

预剪枝:在伸展前判断,效率高,但可能会导致欠拟合,但当数据量很大时,欠拟合的风险大大减小。

后剪枝:没有欠拟合的风险,过拟合风险也不大,但是它要在每个非叶节点计算完后(并不需要等到整棵树建完才判断)才可以判断是否需要剪枝,所需的时间成本是很大的。

与前面不同的是,剪枝通过测试样本来判断,测试样本随着训练样本进入到各个叶节点,划分靠训练样本,剪枝靠测试样本。 
预剪枝与后剪枝虽然差别很大,但是代码却很相近,只要把剪枝的判断(剪枝的判断计算可以不需要移动)从划分前移动到划分后,就像树的遍历,改变一下访问节点的位置就能达到效果,这是递归(栈结构)的一大好处。

# coding: utf-8
from numpy import *
import pandas as pd
import codecs
import operator
import copy
import json
from treePlotter import *


'''
输入:给定数据集   输出:Gini指数

'''
def calcGini(dataSet):
    numEntries = len(dataSet)
    labelCounts = {}
    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
    Gini = 1.0
    for key in labelCounts:
        prob = float(labelCounts[key])/numEntries
        Gini -= prob * prob #log base 2
    return Gini
'''
输入:数据集,划分特征,划分特征的取值         输出:划分完毕后的数据子集
这个函数的作用是对数据集进行划分,对属性axis值为value的那部分数据进行挑选(注:此处得到的子集特征数比划分前少1,少了那个用来划分的数据)
'''
def splitDataSet(dataSet,axis,value):
    returnMat = []
    for data in dataSet:
        if data[axis]==value:
            returnMat.append(data[:axis]+data[axis+1:])
    return returnMat

'''
与上述函数类似,区别在于上述函数是用来处理离散特征值而这里是处理连续特征值
对连续变量划分数据集,direction规定划分的方向,
决定是划分出小于value的数据样本还是大于value的数据样本集
'''
def splitContinuousDataSet(dataSet, axis, value, direction):
    retDataSet = []
    for featVec in dataSet:
        if direction == 0:
            if featVec[axis] > value:
                #原来的错误的
                #retDataSet.append(featVec[:axis] + featVec[axis + 1:])
                #更正之后的
                retDataSet.append(featVec)
        else:
            if featVec[axis] <= value:
                #原来的错误的
                #retDataSet.append(featVec[:axis] + featVec[axis + 1:])
                #更正之后的
                retDataSet.append(featVec)
    return retDataSet

'''
决策树算法中比较核心的地方,究竟是用何种方式来决定最佳划分?
使用信息增益作为划分标准的决策树称为ID3
使用信息增益比作为划分标准的决策树称为C4.5
本题为信息增益的ID3树
从输入的训练样本集中,计算划分之前的熵,找到当前有多少个特征,遍历每一个特征计算信息增益,找到这些特征中能带来信息增益最大的那一个特征。
这里用分了两种情况,离散属性和连续属性
1、离散属性,在遍历特征时,遍历训练样本中该特征所出现过的所有离散值,假设有n种取值,那么对这n种我们分别计算每一种的熵,最后将这些熵加起来
就是划分之后的信息熵
2、连续属性,对于连续值就稍微麻烦一点,首先需要确定划分点,用二分的方法确定(连续值取值数-1)个切分点。遍历每种切分情况,对于每种切分,
计算新的信息熵,从而计算增益,找到最大的增益。
假设从所有离散和连续属性中已经找到了能带来最大增益的属性划分,这个时候是离散属性很好办,直接用原有训练集中的属性值作为划分的值就行,但是连续
属性我们只是得到了一个切分点,这是不够的,我们还需要对数据进行二值处理。
'''
def chooseBestFeatureToSplit(dataSet, labels):
    numFeatures = len(dataSet[0]) - 1
    bestGini = 10000.0
    bestFeature = -1
    bestSplitDict = {}
    for i in range(numFeatures):
        # 对连续型特征进行处理 ,i代表第i个特征,featList是每次选取一个特征之后这个特征的所有样本对应的数据
        featList = [example[i] for example in dataSet]
        # 因为特征分为连续值和离散值特征,对这两种特征需要分开进行处理。
        if type(featList[0]).__name__ == 'float' or type(featList[0]).__name__ == 'int':
            # 产生n-1个候选划分点
            sortfeatList = sorted(featList)
            splitList = []
            for j in range(len(sortfeatList) - 1):
                splitList.append((sortfeatList[j] + sortfeatList[j + 1]) / 2.0)
            bestSplitGini = 10000
            # 求用第j个候选划分点划分时,得到的信息熵,并记录最佳划分点
            for value in splitList:
                newGini = 0.0
                subDataSet0 = splitContinuousDataSet(dataSet, i, value, 0)
                subDataSet1 = splitContinuousDataSet(dataSet, i, value, 1)
                prob0 = len(subDataSet0) / float(len(dataSet))
                newGini += prob0 * calcGini(subDataSet0)
                prob1 = len(subDataSet1) / float(len(dataSet))
                newGini += prob1 * calcGini(subDataSet1)
                if newGini < bestSplitGini:
                    bestSplitGini = newGini
                    bestSplit = value
                    # 用字典记录当前特征的最佳划分点
            bestSplitDict[labels[i]] = bestSplit
            newGini = bestSplitGini
        else:
            uniqueVals = set(featList)
            newGini = 0.0
            # 计算该特征下每种划分的信息熵,选取第i个特征的值为value的子集
            for value in uniqueVals:
                subDataSet = splitDataSet(dataSet, i, value)
                prob = len(subDataSet) / float(len(dataSet))
                newGini += prob * calcGini(subDataSet)
        if newGini < bestGini:
            bestGini = newGini
            bestFeature = i

    # 若当前节点的最佳划分特征为连续特征,则将其以之前记录的划分点为界进行二值化处理
    # 即是否小于等于bestSplitValue
    if type(dataSet[0][bestFeature]).__name__ == 'float' or type(dataSet[0][bestFeature]).__name__ == 'int':
        bestSplitValue = bestSplitDict[labels[bestFeature]]
        labels[bestFeature] = labels[bestFeature] + '<=' + str(bestSplitValue)
        for i in range(shape(dataSet)[0]):
            if dataSet[i][bestFeature] <= bestSplitValue:
                dataSet[i][bestFeature] = 1
            else:
                dataSet[i][bestFeature] = 0
    return bestFeature

'''
输入:类别列表     输出:类别列表中多数的类,即多数表决
这个函数的作用是返回字典中出现次数最多的value对应的key,也就是输入list中出现最多的那个值
'''
def majorityCnt(classList):
    classCount={}
    for vote in classList:
        if vote not in classCount.keys(): classCount[vote] = 0
        classCount[vote] += 1

    sortedClassCount = sorted(classCount.iteritems(), key=operator.itemgetter(1), reverse=True)

    return sortedClassCount[0][0]



# 由于在Tree中,连续值特征的名称以及改为了feature <= value的形式
# 因此对于这类特征,需要利用正则表达式进行分割,获得特征名以及分割阈值
def classify(inputTree, featLabels, testVec):
    firstStr = inputTree.keys()[0]
    if u'<=' in firstStr:
        featvalue = float(firstStr.split(u"<=")[1])
        featkey = firstStr.split(u"<=")[0]
        secondDict = inputTree[firstStr]
        featIndex = featLabels.index(featkey)
        if testVec[featIndex] <= featvalue:
            judge = 1
        else:
            judge = 0
        for key in secondDict.keys():
            if judge == int(key):
                if type(secondDict[key]).__name__ == 'dict':
                    classLabel = classify(secondDict[key], featLabels, testVec)
                else:
                    classLabel = secondDict[key]
    else:
        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


def testing(myTree, data_test, labels):
    error = 0.0
    for i in range(len(data_test)):
        if classify(myTree, labels, data_test[i]) != data_test[i][-1]:
            error += 1
    # print 'myTree %d' % error
    return float(error)

def testing_feat(feat,train_data,test_data,labels):
    class_list = [example[-1] for example in train_data]
    bestFeatIndex = labels.index(feat)
    train_data = [example[bestFeatIndex] for example in train_data]
    test_data = [(example[bestFeatIndex],example[-1]) for example in test_data]
    all_feat = set(train_data)
    error = 0.0
    for value in all_feat:
        class_feat = [ class_list[i] for i in range(len(class_list)) if train_data[i]==value]
        major = majorityCnt(class_feat)
        for data in test_data:
            if data[0]==value and data[1]!=major:
                error+=1.0
    # print 'myTree %d' % error
    return error

def testingMajor(major, data_test):
    error = 0.0
    for i in range(len(data_test)):
        if major != data_test[i][-1]:
            error += 1
    # print 'major %d' % error
    return float(error)

# #后剪枝
# def postPruningTree(inputTree,dataSet,data_test,labels):
#     firstStr=inputTree.keys()[0]
#     secondDict=inputTree[firstStr]
#     classList=[example[-1] for example in dataSet]
#     featkey=copy.deepcopy(firstStr)
#     if u'<=' in firstStr:
#         featkey=firstStr.split(u'<=')[0]
#         featvalue=float(firstStr.split(u'<=')[1])
#     labelIndex=labels.index(featkey)
#     temp_labels=copy.deepcopy(labels)
#     del(labels[labelIndex])
#     for key in secondDict.keys():
#         if type(secondDict[key]).__name__=='dict':
#             if type(dataSet[0][labelIndex]).__name__=='unicode':
#                 inputTree[firstStr][key]=postPruningTree(secondDict[key],\
#                  splitDataSet(dataSet,labelIndex,key),splitDataSet(data_test,labelIndex,key),copy.deepcopy(labels))
#             else:
#                 inputTree[firstStr][key]=postPruningTree(secondDict[key],\
#                 splitContinuousDataSet(dataSet,labelIndex,featvalue,key),\
#                 splitContinuousDataSet(data_test,labelIndex,featvalue,key),\
#                 copy.deepcopy(labels))
#     if testing(inputTree,data_test,temp_labels)<=testingMajor(majorityCnt(classList),data_test):
#         return inputTree
#     return majorityCnt(classList)



'''
主程序,递归产生决策树。
params:
dataSet:用于构建树的数据集,最开始就是data_full,然后随着划分的进行越来越小,第一次划分之前是17个瓜的数据在根节点,然后选择第一个bestFeat是纹理
纹理的取值有清晰、模糊、稍糊三种,将瓜分成了清晰(9个),稍糊(5个),模糊(3个),这个时候应该将划分的类别减少1以便于下次划分
labels:还剩下的用于划分的类别
data_full:全部的数据
label_full:全部的类别
既然是递归的构造树,当然就需要终止条件,终止条件有三个:
1、当前节点包含的样本全部属于同一类别;-----------------注释1就是这种情形
2、当前属性集为空,即所有可以用来划分的属性全部用完了,这个时候当前节点还存在不同的类别没有分开,这个时候我们需要将当前节点作为叶子节点,
同时根据此时剩下的样本中的多数类(无论几类取数量最多的类)-------------------------注释2就是这种情形
3、当前节点所包含的样本集合为空。比如在某个节点,我们还有10个西瓜,用大小作为特征来划分,分为大中小三类,10个西瓜8大2小,因为训练集生成
树的时候不包含大小为中的样本,那么划分出来的决策树在碰到大小为中的西瓜(视为未登录的样本)就会将父节点的8大2小作为先验同时将该中西瓜的
大小属性视作大来处理。
'''

def createTree(dataSet,labels,data_full,labels_full,test_data,mode="unpro"):
    classList=[example[-1] for example in dataSet]
    if classList.count(classList[0])==len(classList):  #注释1
        return classList[0]
    if len(dataSet[0])==1:                             #注释2
        return majorityCnt(classList)
    #平凡情况,每次找到最佳划分的特征
    labels_copy = copy.deepcopy(labels)
    bestFeat=chooseBestFeatureToSplit(dataSet,labels)
    bestFeatLabel=labels[bestFeat]
    if mode=="unpro" or mode=="post":
        myTree = {bestFeatLabel: {}}
    elif mode=="prev":
        if testing_feat(bestFeatLabel,dataSet,test_data,labels_copy)<testingMajor(majorityCnt(classList), test_data):
            myTree = {bestFeatLabel: {}}
        else:
            return majorityCnt(classList)

    featValues=[example[bestFeat] for example in dataSet]
    uniqueVals = set(featValues)

    '''
    刚开始很奇怪为什么要加一个uniqueValFull,后来思考下觉得应该是在某次划分,比如在根节点划分纹理的时候,将数据分成了清晰、模糊、稍糊三块
    ,假设之后在模糊这一子数据集中,下一划分属性是触感,而这个数据集中只有软粘属性的西瓜,这样建立的决策树在当前节点划分时就只有软粘这一属性了,
    事实上训练样本中还有硬滑这一属性,这样就造成了树的缺失,因此用到uniqueValFull之后就能将训练样本中有的属性值都囊括。
    如果在某个分支每找到一个属性,就在其中去掉一个,最后如果还有剩余的根据父节点投票决定。
    但是即便这样,如果训练集中没有出现触感属性值为“一般”的西瓜,但是分类时候遇到这样的测试样本,那么应该用父节点的多数类作为预测结果输出。
    '''
    if type(dataSet[0][bestFeat]).__name__ == 'unicode':

        currentlabel = labels_full.index(labels[bestFeat])
        featValuesFull = [example[currentlabel] for example in data_full]
        uniqueValsFull = set(featValuesFull)

    del(labels[bestFeat])

    '''
    针对bestFeat的每个取值,划分出一个子树。对于纹理,树应该是{"纹理":{?}},显然?处是纹理的不同取值,有清晰模糊和稍糊三种,对于每一种情况,
    都去建立一个自己的树,大概长这样{"纹理":{"模糊":{0},"稍糊":{1},"清晰":{2}}},对于0\1\2这三棵树,每次建树的训练样本都是值为value特征数减少1
    的子集。
    '''
    for value in uniqueVals:
        subLabels = labels[:]
        if type(dataSet[0][bestFeat]).__name__ == 'unicode':
            uniqueValsFull.remove(value)

        myTree[bestFeatLabel][value] = createTree(splitDataSet \
                            (dataSet, bestFeat, value), subLabels, data_full, labels_full,splitDataSet \
                            (test_data, bestFeat, value),mode=mode)
    if type(dataSet[0][bestFeat]).__name__ == 'unicode':
        for value in uniqueValsFull:
            myTree[bestFeatLabel][value] = majorityCnt(classList)

    if mode=="post":
        if testing(myTree, test_data, labels_copy) > testingMajor(majorityCnt(classList), test_data):
            return majorityCnt(classList)
    return myTree



# 读入csv文件数据
def load_data(file_name):
    file = codecs.open(file_name, "r", 'utf-8')
    filedata = [line.strip('\n').split(',') for line in file]
    filedata = [[float(i) if '.' in i else i for i in row] for row in filedata]  # change decimal from string to float
    train_data = [row[1:] for row in filedata[1:12]]
    test_data = [row[1:] for row in filedata[11:]]
    labels = []
    for label in filedata[0][1:-1]:
        labels.append(unicode(label))
    return train_data,test_data,labels



if __name__=="__main__":

    train_data,test_data,labels = load_data("data/西瓜数据集2.0.csv")
    data_full = train_data[:]
    labels_full = labels[:]
    '''
    为了代码的简洁,将预剪枝,后剪枝和未剪枝三种模式用一个参数mode传入建树的过程
    post代表后剪枝,prev代表预剪枝,unpro代表不剪枝
    '''

    mode="unpro"
    myTree = createTree(train_data,labels, data_full, labels_full,test_data,mode = mode)
    # myTree = postPruningTree(myTree,train_data,test_data,labels_full)
    createPlot(myTree)
    print json.dumps(myTree, ensure_ascii=False, indent=4)

未剪枝决策树:

 

预剪枝决策树:

 

后剪枝决策树:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AIGC Studio

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值