决策树之剪枝原理与CART算法

2019-05-26 19:41:40 浏览数 (1)

决策树学习笔记(二)

前言

继续关于决策树的内容,本篇文章主要学习了决策树的剪枝理论和基于二叉树的CART算法。主要内容:

  • 理解决策树损失函数的定义以及物理含义
  • 基尼指数的主要两个作用
  • 理解CART剪枝原理,以及它的基本假设和核心思想

决策树的剪枝

上篇关于决策树的博文实现了ID3和C4.5算法,但我们并没有实现预测函数,以及进行准确率测试。接下来我们将把数据分为训练数据和测试数据集,来看看决策树的分类效果。

数据准备:

访问:https://archive.ics.uci.edu/ml/datasets/Car Evaluation,下载car.data数据集。

space

buying

maint

doors

persons

lug_boot

safety

class

样例1:

vhigh

vhigh

2

2

small

low

unacc

样例2:

vhigh

vhigh

2

2

small

med

unacc

样例3:

vhigh

vhigh

2

2

small

high

unacc

代码语言:javascript复制
| class values
unacc, acc, good, vgood

| attributes
buying:   vhigh, high, med, low.
maint:    vhigh, high, med, low.
doors:    2, 3, 4, 5more.
persons:  2, 4, more.
lug_boot: small, med, big.
safety:   low, med, high.

新建loadData.py,加载数据集:

代码语言:javascript复制
dataSet = []
labels =[]

def createDataSet(fileName):

    with open(fileName) as ifile:
        for line in ifile:
            tokens = line.strip().split(',')
            dataSet.append(tokens)

    labels =['buying','maint','doors','persons','lug_boot','safety']
    return dataSet,labels

新建test.py,编写predict函数:

代码语言:javascript复制
import operator
from math import log

def splitDataSet(dataSet,axis,value):
    """
    按照给定特征划分数据集
    :param axis:划分数据集的特征的维度
    :param value:特征的值
    :return: 符合该特征的所有实例(并且自动移除掉这维特征)
    """

    # 循环遍历dataSet中的每一行数据
    retDataSet = []
    # 找寻 axis下某个特征的非空子集
    for featVec in dataSet:
        if featVec[axis] == value:
            reduceFeatVec = featVec[:axis] # 删除这一维特征
            reduceFeatVec.extend(featVec[axis 1:])
            retDataSet.append(reduceFeatVec)
    return retDataSet

# 计算的始终是类别标签的不确定度
def calcShannonEnt(dataSet):
    """
    计算训练数据集中的Y随机变量的香农熵
    :param dataSet:
    :return:
    """
    numEntries = len(dataSet) # 实例的个数
    labelCounts = {}
    for featVec in dataSet: # 遍历每个实例,统计标签的频次
        currentLabel = featVec[-1] # 表示最后一列
        # 当前标签不在labelCounts map中,就让labelCounts加入该标签
        if currentLabel not in labelCounts.keys(): labelCounts[currentLabel] =0
        labelCounts[currentLabel]  =1


    shannonEnt = 0.0
    for key in labelCounts:
        prob = float(labelCounts[key]) / numEntries
        shannonEnt -= prob * log(prob,2) # log base 2
    return shannonEnt


def calcConditionalEntropy(dataSet,i,featList,uniqueVals):
    """
    计算x_i给定的条件下,Y的条件熵
    :param dataSet: 数据集
    :param i: 维度i
    :param featList: 数据集特征列表
    :param unqiueVals: 数据集特征集合
    :return: 条件熵
    """
    ce = 0.0
    for value in uniqueVals:
        subDataSet = splitDataSet(dataSet,i,value)
        prob = len(subDataSet) / float(len(dataSet)) # 极大似然估计概率
        ce  = prob * calcShannonEnt(subDataSet) #∑pH(Y|X=xi) 条件熵的计算 
    return ce

# 计算信息增益
def calcInformationGain(dataSet,baseEntropy,i):
    """
    计算信息增益
    :param dataSet: 数据集
    :param baseEntropy: 数据集中Y的信息熵
    :param i: 特征维度i
    :return: 特征i对数据集的信息增益g(dataSet | X_i)
    """
    featList = [example[i] for example in dataSet] # 第i维特征列表
    uniqueVals = set(featList) # 换成集合 - 集合中的每个元素不重复
    newEntropy = calcConditionalEntropy(dataSet,i,featList,uniqueVals)
    infoGain = baseEntropy - newEntropy # 信息增益
    return infoGain



def majorityCnt(classList):
    """
    返回出现次数最多的分类名称
    :param classList: 类列表
    :retrun: 出现次数最多的类名称
    """

    classCount = {}
    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 chooseBestFeatureToSplitByID3(dataSet):
    """
    选择最好的数据集划分
    :param dataSet:
    :return:
    """
    numFeatures = len(dataSet[0]) -1 # 最后一列是分类
    baseEntropy = calcShannonEnt(dataSet)
    bestInfoGain = 0.0
    bestFeature = -1
    for i in range(numFeatures): # 遍历所有维度特征
        infoGain = calcInformationGain(dataSet,baseEntropy,i)
        if(infoGain > bestInfoGain):
            bestInfoGain = infoGain
            bestFeature = i
    return bestFeature # 返回最佳特征对应的维度


def calcInformationGainRate(dataSet,baseEntropy,i):
        """
        计算信息增益比
        :param dataSet: 数据集
        :param baseEntropy: 数据集中Y的信息熵
        :param i: 特征维度i
        :return: 特征i对数据集的信息增益g(dataSet|X_i)
        """
        numEntries = len(dataSet)
        labelCounts = {}
        for featVec in dataSet:
                currentLabel = featVec[i]
                if currentLabel not in labelCounts.keys(): labelCounts[currentLabel] =0
                labelCounts[currentLabel]  =1
        shannonEnt = 0.0
        for key in labelCounts:
                prob = float(labelCounts[key]) / numEntries
                shannonEnt -= prob * log(prob,2)

        return calcInformationGain(dataSet,baseEntropy,i) / shannonEnt

def chooseBestFeatureToSplitByC45(dataSet):
        """
        选择最好的数据集划分方式
        :param dataSet:
        :return:
        """
        numFeatures = len(dataSet[0]) -1 # 最后一列是分类
        baseEntropy = calcShannonEnt(dataSet)
        bestInfoGainRate =0.0
        bestFeature = -1
        for i in range(numFeatures):
                infoGainRate = calcInformationGainRate(dataSet,baseEntropy,i)
                if (infoGainRate > bestInfoGainRate):
                        bestInfoGainRate = infoGainRate
                        bestFeature = i
        return bestFeature


def noInformationGainToSplitByID3(dataSet):
        """
        不使用信息增益概念,而是直接判断条件熵的大小
        """
        numFeatures = len(dataSet[0]) -1
        bestConditionEntropy = 1.0
        bestFeature =-1
        for i in range(numFeatures):
                featList = [example[i] for example in dataSet]
                uniqueVals = set(featList)
                conditionEntropy = calcConditionalEntropy(dataSet,i,featList,uniqueVals)
                if (conditionEntropy < bestConditionEntropy):
                        bestConditionEntropy = conditionEntropy
                        bestFeature =i
        return bestFeature


def createTree(dataSet,labels,chooseBestFeatureToSplitFunc = chooseBestFeatureToSplitByID3):
    """
    创建决策树
    :param dataSet: 数据集
    :param labels: 数据集每一维的名称
    :return: 决策树
    """
    classList = [example[-1] for example in dataSet] # 类别列表
    if classList.count(classList[0]) == len(classList): # 统计属于列别classList[0]的个数
        return classList[0] # 当类别完全相同则停止继续划分
    if len(dataSet[0]) ==1: # 当只有一个特征的时候,遍历所有实例返回出现次数最多的类别
        return majorityCnt(classList) # 返回类别标签
    bestFeat = chooseBestFeatureToSplitFunc(dataSet)
    bestFeatLabel = labels[bestFeat]
    myTree ={bestFeatLabel:{}}  # map 结构,且key为featureLabel
    del (labels[bestFeat])
    # 找到需要分类的特征子集
    featValues = [example[bestFeat] for example in dataSet]
    uniqueVals = set(featValues)
    for value in uniqueVals:
        subLabels = labels[:] # 复制操作
        myTree[bestFeatLabel][value] = createTree(splitDataSet(dataSet,bestFeat,value),subLabels)
    return myTree

# change map feature to label's index
def mapFeatureToLabelIndex(map,labels):
        for key in map.keys():
                for i in range(len(labels)):
                        if key == labels[i]:
                                return key,i


# 决策树预测函数
def predict(testData,decisionTree,labels):
        # 获得决策树结点的下标
        feature_label,feature_index = mapFeatureToLabelIndex(decisionTree,labels)
        tree = decisionTree[feature_label][testData[feature_index]]
        # 判断该树是叶子结点还是子结点
        if (~isinstance(tree,dict)): # 如果是叶子结点,则直接返回结果
                return tree
        else: # 子结点则继续递归
                return predict(testData,tree,labels)

# 决策树准确率判断
def calPrecision(dataSet,predictSet):
        length = len(dataSet)
        count = 0
        for i in range(length):
              if dataSet[i][-1] == predictSet[i]:
                        count  =1
        return count / length *100

# 测试不同数据集的决策树构建
import loadData as ld
dataSet,labels = ld.createDataSet('car.data')
import copy
predict_labels = copy.copy(labels)

# split dataSet into trainingSet and testSet
import numpy as np
np.random.shuffle(dataSet)

m = len(dataSet)
# 定义交叉验证比例
rate = 0.7
training_len = int(rate * m);

trainingSet,testSet = dataSet[0:training_len],dataSet[training_len:-1]
myTree = createTree(trainingSet,labels,chooseBestFeatureToSplitByC45)

# 预测训练集
predict_result =[]
for data in testSet:
        result = predict(data[0:-1],myTree,predict_labels)
        predict_result.append(result)

# 测试训练集准确率
print("decision Tree predict precision: %.2f"�lPrecision(testSet,predict_result),"%")

经过多次测试结果得:

代码语言:javascript复制
decision Tree predict precision: 35.71%
decision Tree predict precision: 32.24%
decision Tree predict precision: 35.14%
decision Tree predict precision: 33.78%

决策树生成算法递归地产生决策树,直到不能继续下去为止。这样产生的树往往对训练数据的分类很准确,但对未知的测试数据的分类却没那么准确,即出现过拟合现象。过拟合的原因在于学习时过多地考虑如何提高对训练数据的正确分类,从而构建出过于复杂的决策树。解决这个问题的办法是考虑决策树的复杂度,对已生成的决策树进行简化。(如何证明?数学能否证明?)

以下是数学与程序的分割线


损失函数的定义

书中所表述的内容可以简单理解为:训练集分类越准确,决策树的复杂度越高。训练集分类模糊,决策树的复杂度就相对比较低。我们先前写的所有ID3和C4.5算法的分类准确率都是100%。如果拿来一堆测试数据,往往测试结果告诉我们分类准确率并不高。上述测试集结果为35%附近。模型复杂度和损失函数为何成反比?由经验得?为何要调和分类模糊和决策树的复杂度即能提升准确率?

先来看看书中关于损失函数的定义。决策树的剪枝往往通过极小化决策树整体的损失函数或代价函数来实现。设树T的叶结点个数为|T|vert Tvert,t是树T的叶结点,该叶结点有NtN_t个样本点,其中k类样本点有NtkN_{tk}个,k=1,2,…,K,Ht(T)H_t(T)为叶结点t上的经验熵,α≥0alpha ge 0为参数,则决策树学习的损失函数可以定义为:

Cα(T)=∑t=1|T|NtHt(T) α|T|

C_{alpha}(T) = sum_{t=1}^{vert T vert}N_tH_t(T) alpha vert T vert 其中经验熵为

Ht(T)=−∑kNtkNtlogNtkNt

H_t(T) = -sum_k frac{N_{tk}}{N_t}logfrac{N_{tk}}{N_t}

在这里我存在两个疑问:

  • 叶结点上为什么会有有限个NtN_t个样本点?且为何存在k类样本点?即在该叶结点上,所有类别标签应该都是一致的。(可能解释:1.在该叶结点上没有足够的特征信息再把不一致的标签分开。2.存在噪声点,何谓噪声,在类群中有极少个与类群不符的标签。)
  • 假如上述问题成立,那么叶结点存在的噪声是不需要做处理的,因为这种噪声天然的被大量类群所包围,而决策树生成过程中,在叶结点上取条件概率大的那一类作为该叶结点的类别标签。由此,难道在决策树生成时,某个维度上的特征是可以自动被切分的?如果此条件成立,那么上述式子区分噪声点才有了意义。

损失函数的物理含义

在理解损失函数时,首先明确两个物理含义,信息熵和叶结点个数|T|vert T vert,信息熵我更喜欢把它成为不确定度,在这里不确定度还乘以了一个NtN_t因子,该式子的含义就变成了不确定次数。关于不确定度和不确定次数的区别,它们的定义过程只是少了一个归一化系数(样本个数NN)。它们之间的具体联系可以参看博文 决策树之理解ID3算法和C4.5算法。

那么叶结点个数有反映了决策树什么样的性质呢?为何把这两个量构成的新模型(损失函数)就能够提升决策时的预测准确率呢?首先我们来画一个最简单的决策树模型。见下图:

从图中我们能看到,根结点为特征x,且分类规则为x<2和x≥2xlt 2 和 x ge 2,在二维空间,决策树就把整个二维空间划分成左半部分和右半部分,即特征空间被两条rule所瓜分。rule1说在这空间里的所有点都属于“是”标签,而rule2说在我这空间里的点都属于“否”标签。叶结点的个数对应为几条rule。叶节点数越多,rule越多,空间将被多个rule共同平分,类似于占领自家土地的过程。

我们再来看一个稍微复杂的例子,自带噪声(且噪声点能够被该决策树自动学习到)。

这个决策树中,在rule3中只有一个点,且类别标签为“是”。在它附近的点都属于“否”标签,它很有可能就是一个噪声点。但由于决策树能够自动提取特征,所以该空间被4条规则所瓜分,对应的,决策树叶结点个数为4。有了这幅,我们便可以分析损失函数是如何有效的去除噪声点的。目前该损失函数中α|T|alphavert T vert的作用,我只能看到这一点。

在这个特殊的情况下,根节点选取x或者y为特征向量都无关紧要,因为它们的不确定度都是一样的。我们以x为根节点进行分析。这里的一个最大疑问是,在进行决策树剪枝运算时,计算的是子结点下,各样本点的不确定度啊,为什么书中说是叶结点呢?(可能是已经假设了这些样本点已经归并到上一层子结点上了。)即现在的决策树形态为,如下图:

在这种情况下,我们可以计算出rule1空间中,即左侧叶结点的不确定次数。如果是噪声点,那么它的不确定次数是很低的。看书中信息熵的那张图,不信自己算一下也行。

噪声点出现的概率极低,那么Ht(T)H_t(T)近似为0,由此,我们可以粗暴的认为:

Cα(T)=α|T|

C_{alpha}(T) = alphavert T vert 剪枝的过程必然导致叶结点个数的减少,因此由该损失函数得,必然选择叶结点树少的那类决策树,也就是选择模型简单的决策树。当然,如果αalpha取0,那么决策树只受叶结点的不确定次数影响。

正如书中的结论:剪枝,就是当αalpha确定时,选择损失函数最小的模型,即损失函数最小的子树。当αalpha值确定时,子树越大,往往与训练数据的拟合好,但是模型的复杂度就越高;相反,子树越小,模型的复杂度就越低,但是往往与训练数据的拟合不好。损失函数正好表示了对两者的平衡。

个人疑云:

这些理论都建立在噪声影响了模型的复杂度之上,这好像是被大家广泛认可的公理。但为什么受噪声影响,任何一种模型的复杂度就随着增加嘛?是什么内在的因素影响了模型的复杂度?它们之间就没有任何联系可以用来解释嘛?就因为噪声使得系统变得混沌?现有模型无法很好的表达不确定因素,或者说现有模型都是建立在确定因素上,而噪声的引入,让确定变成不确定,模型为了找到规律,在确定因素中需找更加复杂的表现形式来拟合不确定因素。而确定的东西是没法表达不确定因素,由此带来了复杂度。呵呵,得看看哲学了。

最近,在知乎上提了这个问题,大神给出了他关于这个问题的理解。并从另外一个视角理解什么是信息熵,这种信息熵的理解更加贴合计算机领域知识。详见【如何理解机器学习中噪声影响模型复杂度问题?】

有了损失函数的表达式,我们就能够生成我们想要的决策树了。

算法(树的剪枝算法):

输入:生成算法产生的整个树T,参数αalpha: 输出:修建后的子树TαT_{alpha} (1) 计算每个结点的经验熵 (2) 递归地从树的叶结点向上回缩 设一组叶结点回缩到其父结点之前与之后的整体树分别为TB与TAT_B与T_A,其对应的损失函数值分别为Cα(TB)与Cα(TA)C_{alpha}(T_B)与C_{alpha}(T_A),如果 Cα(TA)≤Cα(TB) C_{alpha}(T_A)le C_{alpha}(T_B) 则进行剪枝,即将父结点变为新的叶结点。 (3) 返回(2),直至不能继续为止,得到损失函数最小的子树TαT_{alpha}

只需要考虑两个树的损失函数的差,其计算可以在局部进行。所以,决策树的剪枝算法可以由一种动态规划的算法来实现。我们暂且不实现剪枝算法,在介绍完CART算法后,我们在此基础上实现。

CART算法

分类与回归树模型是由Breiman等人在1984年提出,是应用广泛的决策树学习方法。CART同样由特征选择、树的生成及剪枝组成,即可以用于分类也可以用于回归。以下将用于分类与回归的树统称为决策树。

CART是在给定输入随机变量X条件下输出随机变量Y的条件概率分布的学习方法。CART假设决策树是二叉树,内部结点特征的取值为“是”和“否”,左分支是取值为“是”的分支,右分支是取值为“否”的分支。这样的决策树等价于递归地二分每个特征,将输入空间即特征空间划分为有限个单元,并在这些单元上确定预测的概率分布,也就是输入给定的条件下输出的条件概率分布。

疑问:

  • 为何是二叉树?不是其他形式的树?

CART算法由以下两步组成: 1. 决策树生成:基于训练数据集生成决策树,生成的决策树要尽量大; 2. 决策树剪枝:用验证数据集最已生成的树进行剪枝并选择最优子树,这时用损失函数最小作为剪枝的标准。

CART算法主要分为两大部分: 1. 回归数的生成,针对Y是连续变量。 2. 分类树的生成,针对Y是离散变量。

本篇文章主要理解分类树的生成。

分类树的生成

分类树用基尼指数选择最优特征,同时决定该特征的最优二值切分点。存在两个疑问:

  • 基尼指数和信息熵有何不同?为何CART算法选择基尼指数而不是信息熵。
  • 基尼指数是如何选择最优二值切分点的。(CART的前提条件,决策树必须为二叉树。)

定义(基尼指数) 分类问题中,假设有K个类,样本点属于第k类的概率为pkp_k,则概率分布的基尼指数定义为

Gini(p)=∑k=1Kpk(1−pk)=1−∑k=1Kp2k

Gini(p) = sum_{k=1}^Kp_k(1-p_k) = 1-sum_{k=1}^Kp_k^2 对于二类分类问题,若样本点属于第1个类的概率是p,则概率分布的基尼指数为

Gini(p)=2p(1−p)

Gini(p) = 2p(1-p) 对于给定的样本集合D,其基尼指数为

Gini(D)=1−∑k=1K(|Ck||D|)2

Gini(D) = 1-sum_{k=1}^K(frac{vert C_k vert}{vert D vert})^2 这里,CkC_k是D中属于第k类的样本子集,K是类的个数。

如果样本集合D根据特征A是否取某一可能值a被分割成D1D_1和D2D_2两部分,即

D1={(x,y)∈D|A(x)=a},D2=D−D1

D_1={(x,y) in D| A(x) =a},D_2 = D-D_1 则在特征A的条件下,集合D的基尼指数定义为

Gini(D,A)=|D1||D|Gini(D1) |D2||D|Gini(D2)

Gini(D,A) = frac{vert D_1vert}{vert Dvert}Gini(D_1) frac{vert D_2vert}{vert Dvert}Gini(D_2) 基尼指数Gini(D)表示集合D的不确定性,基尼指数Gini(D,A)表示A =a 分割后集合D的不确定性。基尼指数值越大,样本集合的不确定性也越大,这一点与熵相似。

上述就是基尼指数的定义了,定义很简单,但书中同样没有解释为什么使用基尼指数而不是信息熵作为CART算法。这一部分我也无法理解为什么不用信息熵来解决,而用基尼系数。但书中给了我们一张二类分类中基尼指数,熵之半和分类误差率的关系。起码在二元分类中,信息熵和基尼指数对特征选取的作用是等价的。见图:

简单来说,基尼指数反映了数据集合的混乱程度。当基尼指数越大时,当前数据越混沌的,分布均匀时取极值。

举个简单的例子,来分析基尼指数是如何一并选择特征向量和寻找最佳切分点的。

下表是一个由15个样本组成的贷款申请训练数据。数据包括贷款申请人的4个特征:第1个特征是年龄,有三个可能值:青年,中年,老年;第2个特征是有工作,有2个可能值:是,否;第3个特征是有自己的房子,有两个可能值:是,否;第四个特征是信贷情况,有3个可能值:非常好,好,一般。表的最后一列是类别,是否同意贷款,取二个值:是,否。

希望通过所给的训练数据学习一个贷款申请的模型,用以对未来的贷款申请进行分类,即当新的客户提出贷款申请时,根据申请人的特征利用该模型决定是否批准贷款申请。

解:首先计算各特征的基尼指数,选择最优特征以及最优切分点。分别以A1,A2,A3,A4A_1,A_2,A_3,A_4表示年龄、有工作、有自己的房子和信贷情况4个特征,并以1,2,3表示年龄的值为青年、中年和老年,以1,2表示有工作和有自己的房子的值是和否,以1,2,3表示信贷情况的值为非常好、好和一般。

求特征A1A_1的基尼指数:

space

青年(总量 = 5)

中年、老年(总量 = 10)

能否贷款

否,否,是,是,否

否,否,是,是,是,是,是,是,是,否

基尼指数在选取最优切分点的过程中,会分为当前特征标签和其他特征标签两类。所以

Gini(D,A1=1)=515[2×25×(1−25)] 1015[2×710×(1−710)]=0.44

Gini(D,A_1=1) = frac{5}{15}[2timesfrac{2}{5}times(1-frac{2}{5})] frac{10}{15}[2timesfrac{7}{10}times(1-frac{7}{10})]=0.44 简单说明下,第一部分是青年标签里能否贷款的数据混沌度,第二部分是中年和老年加在一起的数据混沌度。同理:

Gini(D,A1=2)=0.48

Gini(D,A_1=2) =0.48

Gini(D,A1=3)=0.44

Gini(D,A_1=3) =0.44 由于Gini(D,A1=1)=Gini(D,A1=3)=0.44Gini(D,A_1=1)=Gini(D,A_1=3) =0.44,且最小,所以A1=1和A1=3A_1 =1 和 A_1 =3都可以选作A1A_1的最优切分点。

求特征A2和A3A_2和A_3的基尼指数:

Gini(D,A2=1)=0.32

Gini(D,A_2=1) =0.32

Gini(D,A3=1)=0.27

Gini(D,A_3=1) =0.27 由于A2和A3A_2和A_3只有一个切分点,所以它们就是最优切分点。

求特征A4A_4的基尼指数:

Gini(D,A4=1)=0.36

Gini(D,A_4=1) =0.36

Gini(D,A4=2)=0.47

Gini(D,A_4=2) =0.47

Gini(D,A4=3)=0.32

Gini(D,A_4=3) =0.32 Gini(D,A4=3)Gini(D,A_4=3)最小,所以A4=3A_4 =3为A4A_4的最优切分点。

在A1,A2,A3,A4A_1,A_2,A_3,A_4几个特征中,Gini(D,A3=1)=0.27Gini(D,A_3=1) =0.27最小,所以选择特征A3A_3为最优特征,A3=1A_3=1为其最优切分点。于是根结点生成两个子结点,一个是叶结点。对另一个结点继续使用以上方法在A1,A2,A4A_1,A_2,A_4中选择最优特征及其最优切分点,结果是A2=1A_2=1,以此计算得知,所得结点都是叶结点。

简单总结 从上述解题过程中,我们发现Gini指数不仅用来选取最优特征,还用来选择最优切分点。而在ID3和C4.5算法中,信息熵并没有用此来计算最优切分点而仅仅用来选择最优特征。当然,我们也可以自己实现信息熵的最优切分点来看看决策树的生成效果如何。

算法(CART生成算法)

输入:训练数据集D,停止计算的条件; 输出:CART决策树; 根据训练数据集,从根节点开始,递归地对每个结点进行以下操作,构建二叉决策树: (1)设结点的训练数据集为D,计算现有特征对该数据集的基尼指数。此时对每一个特征A,对其可能取的每个值a,根据样本点对A=a的测试为“是”或“否”将D分割成D1D_1和D2D_2两部分,利用基尼指数计算公式计算。 (2)在所有可能的特征A以及它们所有可能的切分点a中,选择基尼指数最小的特征及其对应的切分点作为最优特征与最优切分点。依最优特征与最优切分点,从现结点生成两个子结点,将训练数据集依特征分配到两个子结点中去。 (3)对两个子结点递归地调用(1),(2),直至满足停止条件。 (4)生成CART决策树 算法停止计算的条件是结点中的样本个数小于预定阈值,或样本集的基尼指数小于预定阈值,或者没有更多特征。


Code Time

终于到了我们编代码的时间了,我们先模仿ID3算法,构建贷款申请样本数据的分类树。

数据准备 新建cart.py文件,生成数据:

代码语言:javascript复制
def createDataSet():
    """
    创建数据集
    """
    dataSet = [[u'1000',u'青年', u'否', u'否', u'一般', u'拒绝'],
                [u'2000',u'青年', u'否', u'否', u'好', u'拒绝'],
                [u'7000',u'青年', u'是', u'否', u'好', u'同意'],
                [u'7100',u'青年', u'是', u'是', u'一般', u'同意'],
                [u'3000',u'青年', u'否', u'否', u'一般', u'拒绝'],
                [u'3500',u'中年', u'否', u'否', u'一般', u'拒绝'],
                [u'3600',u'中年', u'否', u'否', u'好', u'拒绝'],
                [u'8000',u'中年', u'是', u'是', u'好', u'同意'],
                [u'9000',u'中年', u'否', u'是', u'非常好', u'同意'],
                [u'9200',u'中年', u'否', u'是', u'非常好', u'同意'],
                [u'8600',u'老年', u'否', u'是', u'非常好', u'同意'],
                [u'7800',u'老年', u'否', u'是', u'好', u'同意'],
                [u'10000',u'老年', u'是', u'否', u'好', u'同意'],
                [u'6500',u'老年', u'是', u'否', u'非常好', u'同意'],
                [u'3000',u'老年', u'否', u'否', u'一般', u'拒绝'],
                ]
    labels = [u'工资',u'年龄', u'有工作', u'有房子', u'信贷情况']
    predict_labels = [u'工资',u'年龄', u'有工作', u'有房子', u'信贷情况']
    # 返回数据集和每个维度的名称
    return dataSet, labels,predict_labels

计算数据集的基尼指数:

代码语言:javascript复制
# 计算数据集的基尼指数
def calcGini(dataSet):
    numEntries = len(dataSet)
    labelCounts ={}
    # 给所有可能分类创建字典
    for featVec in dataSet:
        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
    return Gini

划分数据集,便于构造决策树的子结点:

代码语言:javascript复制
def splitDataSet(dataSet,axis,value):
    """
    按照给定特征划分数据集
    :param axis:划分数据集的特征的维度
    :param value:特征的值
    :return: 符合该特征的所有实例(并且自动移除掉这维特征)
    """

    # 循环遍历dataSet中的每一行数据
    retDataSet = []
    # 找寻 axis下某个特征的非空子集
    for featVec in dataSet:
        if featVec[axis] == value:
            reduceFeatVec = featVec[:axis] # 删除这一维特征
            reduceFeatVec.extend(featVec[axis 1:])
            retDataSet.append(reduceFeatVec)
    return retDataSet

划分指定特征标签数据集,便于计算在某特征下的基尼指数:

代码语言:javascript复制
def splitOtherDataSetByValue(dataSet,axis,value):
        """
    按照给定特征划分数据集
    :param axis:划分数据集的特征的维度
    :param value:特征的值
    :return: 不符合该特征的所有实例(并且自动移除掉这维特征)
    """
    # 循环遍历dataSet中的每一行数据
        retDataSet = []
    # 找寻 axis下某个特征的非空子集
        for featVec in dataSet:
                if featVec[axis] != value:
                        reduceFeatVec = featVec[:axis] # 删除这一维特征
                        reduceFeatVec.extend(featVec[axis 1:])
                        retDataSet.append(reduceFeatVec)
        return retDataSet

辅助方法:

代码语言:javascript复制
def majorityCnt(classList):
    """
    返回出现次数最多的分类名称
    :param classList: 类列表
    :retrun: 出现次数最多的类名称
    """

    classCount = {}
    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]

根据最优特征的最优切分点二值化dataSet:

代码语言:javascript复制
def binaryZationDataSet(bestFeature,bestSplitValue,dataSet):
        # 求特征标签数
        featList = [example[bestFeature] for example in dataSet]
        uniqueValues = set(featList)

        # 特征标签输超过2,对数据集进行二值划分 为了看出决策树构造时的区别,这里特征标签为2时也进行处理
        if len(uniqueValues) >= 2:
                for i in range(len(dataSet)):
                        if dataSet[i][bestFeature] == bestSplitValue: # 不做处理
                                pass
                        else:
                                dataSet[i][bestFeature] = '其他' 

计算最好的数据集划分:

代码语言:javascript复制
def chooseBestFeatureToSplitByCART(dataSet):
    numFeatures = len(dataSet[0]) -1
    bestGiniIndex = 1000000.0
    bestSplictValue =[]
    bestFeature = -1
    # 计算Gini指数
    for i in range(numFeatures):
        featList = [example[i] for example in dataSet]
        # 这里只针对离散变量 & 特征标签
        uniqueVals = set(featList)
        bestGiniCut = 1000000.0
        bestGiniCutValue =[]
        Gini_value =0.0
        # 计算在该特征下每种划分的基尼指数,并且用字典记录当前特征的最佳划分点
        for value in uniqueVals:
            # 计算subDataSet的基尼指数
            subDataSet = splitDataSet(dataSet,i,value)
            prob = len(subDataSet) / float(len(dataSet))
            Gini_value = prob * calcGini(subDataSet)
            # 计算otherDataSet的基尼指数
            otherDataSet = splitOtherDataSetByValue(dataSet,i,value)
            prob = len(otherDataSet) / float(len(dataSet))
            Gini_value = Gini_value   prob * calcGini(otherDataSet)
            # 选择最优切分点
            if Gini_value < bestGiniCut:
                bestGiniCut = Gini_value
                bestGiniCutValue = value

        # 选择最优特征向量
        GiniIndex = bestGiniCut
        if GiniIndex < bestGiniIndex:
            bestGiniIndex = GiniIndex
            bestSplictValue = bestGiniCutValue
            bestFeature = i
            print(bestFeature,bestSplictValue)

    # 若当前结点的划分结点特征中的标签超过3个,则将其以之前记录的划分点为界进行二值化处理
    binaryZationDataSet(bestFeature,bestSplictValue,dataSet)
    return bestFeature

构造决策二叉树:

代码语言:javascript复制
def createTree(dataSet,labels,chooseBestFeatureToSplitFunc = chooseBestFeatureToSplitByID3):
    """
    创建决策树
    :param dataSet: 数据集
    :param labels: 数据集每一维的名称
    :return: 决策树
    """
    classList = [example[-1] for example in dataSet] # 类别列表
    if classList.count(classList[0]) == len(classList): # 统计属于列别classList[0]的个数
        return classList[0] # 当类别完全相同则停止继续划分
    if len(dataSet[0]) ==1: # 当只有一个特征的时候,遍历所有实例返回出现次数最多的类别
        return majorityCnt(classList) # 返回类别标签
    bestFeat = chooseBestFeatureToSplitFunc(dataSet)
    bestFeatLabel = labels[bestFeat]
    myTree ={bestFeatLabel:{}}  # map 结构,且key为featureLabel
    del (labels[bestFeat])
    # 找到需要分类的特征子集
    featValues = [example[bestFeat] for example in dataSet]
    uniqueVals = set(featValues)
    for value in uniqueVals:
        subLabels = labels[:] # 复制操作
        myTree[bestFeatLabel][value] = createTree(splitDataSet(dataSet,bestFeat,value),subLabels,chooseBestFeatureToSplitFunc)
    return myTree

测试:

代码语言:javascript复制
dataSet,labels,predict_labels= createDataSet()
myTree = createTree(dataSet,labels,chooseBestFeatureToSplitByCART)

可视化:

代码语言:javascript复制
from pylab import *

mpl.rcParams['font.sans-serif'] = ['SimHei']  # 指定默认字体
mpl.rcParams['axes.unicode_minus'] = False  # 解决保存图像时负号'-'显示为方块的问题

treePlotter.createPlot(myTree)

关于可视化的代码,参看博文【 决策树之理解ID3算法和C4.5算法】

实验结果如下:

输出

代码语言:javascript复制
myTree
{'有房子': {'是': '同意', '其他': {'有工作': {'是': '同意', '其他': '拒绝'}}}}

在还未出实验结果前,个人以为决策二叉树第二个子结点的特征标签为薪资结构,然而并非如我想象的那样,顿时有一种感悟。基尼指数天然的解决了ID3算法所提出的问题。为什么?因为基尼指数不管特征标签个数是多少,它都把特征分为两个标签,即在薪资结构中,可能会分为的特征标签为:“1000”,“其他”。在计算该基尼指数时,用二分法来衡量它们的不确定性,就变成了信息熵在二元分类中最本质的问题。即不会出现统计上因为特征标签一一对应于分类标签,而导致统计失效的问题。

我们再来加载car.data数据,来看看CART实验效果。数据即为博文之初的测试数据,这里不再重复。

修改预测函数,使得测试数据符合二分法:

代码语言:javascript复制
# change map feature to label's index
def mapFeatureToLabelIndex(map,labels):
        for key in map.keys():
                for i in range(len(labels)):
                        if key == labels[i]:
                                return key,i


# 决策树预测函数
def predict(testData,decisionTree,labels):
        # 获得决策树结点的下标
        feature_label,feature_index = mapFeatureToLabelIndex(decisionTree,labels)
        # 判断feature_index所指的特征标签是否在该类中
        if testData[feature_index] not in decisionTree[feature_label]:
                testData[feature_index] = '其他'
        tree = decisionTree[feature_label][testData[feature_index]]
        # 判断该树是叶子结点还是子结点
        if (not isinstance(tree,dict)): # 如果是叶子结点,则直接返回结果
                return tree
        else: # 子结点则继续递归
                return predict(testData,tree,labels)

# 决策树准确率判断
def calPrecision(dataSet,predictSet):
        length = len(dataSet)
        count = 0
        for i in range(length):
              if dataSet[i][-1] == predictSet[i]:
                        count  =1
        return count / length *100

测试实验数据准确率:

代码语言:javascript复制
################# 实验数据的测试 测试文件 car.data ###########################
import loadData as ld
dataSet,labels = ld.createDataSet('car.data')
import copy
predict_labels = copy.copy(labels)


# 训练集交叉验证抽取算法
import numpy as np
np.random.shuffle(dataSet)

m = len(dataSet)
rate = 0.7
training_len = int(rate * m);

trainingSet,testSet = dataSet[0:training_len],dataSet[training_len:-1]

myTree = createTree(trainingSet,labels,chooseBestFeatureToSplitByCART)

# 数据可视化
from pylab import * 
mpl.rcParams['font.sans-serif'] = ['SimHei']  # 指定默认字体
mpl.rcParams['axes.unicode_minus'] = False  # 解决保存图像时负号'-'显示为方块的问题
treePlotter.createPlot(myTree)


# 根据生成的最优切分点和最优特征,生成相应数据格式的testData

predict_result =[]
for data in testSet:
        result = predict(data[0:-1],myTree,predict_labels)
        predict_result.append(result)

print("decision Tree predict precision: %.2f"�lPrecision(testSet,predict_result),"%")

测试结果如下:

代码语言:javascript复制
decision Tree predict precision: 83.40 %

使用未剪枝的CART算法,也没有提高预测的准确率。 感谢网友lpkinging指出一个错误,在决策树预测函数中使用最新的判断语句:

代码语言:javascript复制
if ( not isinstance(tree,dict)): # 如果是叶子结点,则直接返回结果

准确率达到了83.40%

但我们发现,决策树变成了二叉树,同样的实验效果,可视化图变得简单,且易于呈现。

CART剪枝实现

书中基本思路:CART剪枝算法从“完全生长”的决策树的底端剪去一些子树,使决策树变小(模型变简单),从而能够对未知数据有更准确的预测。CART剪枝算法由两步组成:首先从生成算法产生的决策树T0T_0底端开始不断剪枝,直到T0T_0的根结点,形成一个子树序列{T0,T1,...,Tn}{T_0,T_1,...,T_n};然后通过交叉验证法在独立的验证数据集上对子树序列进行测试,从中选择最优子树。

以下是数学与程序的分割线


当看到上述定义时,并不知道他在说什么,也不理解为何这么做。因此,我们还是从头到尾,按照一般人的思维来慢慢逼近CART剪枝。这个过程必不可少,从中我们也能挖掘出它实现CART最基本的假设和核心的思想是什么。

前文已经提到了决策树的剪枝算法了,理所当然,我们是顺着这个思路来讲解下决策树剪枝的关键步骤。我们定义了

Cα(T)=∑t=1|T|NtHt(T) α|T|

C_{alpha}(T) = sum_{t =1}^{vert Tvert}N_tH_t(T) alphavert Tvert 该定义表示了决策树的损失函数。whaterver它是什么,现在有了损失函数这个衡量标准,并且假设我们已经根据training set生成了一棵复杂的决策树,且参数αalpha已知。算法该如何实现决策树的剪枝呢?

首先明确一个概念,原本躺在那的一堆数据集,在没有决策规则被挖掘时,我们只能知道数据集中的分类标签的情况。如银行贷款问题中,银行只知道有多少人可以贷款,有多少人不可以贷款,所以躺在那的数据集的不确定度是最高的。关于这部分的理解可以参看博文【 决策树之理解ID3算法和C4.5算法】,由此决策树越复杂,整体数据的不确定度越低。(数据被规则所明确,即银行在得知用户有房子的情况下,能根据训练数据统计出所有用户都是可以贷款的,这样的规则被银行挖掘出来了。)那么,显而易见,规则越多,数据分类的不确定性将大大降低。

咱们来看看决策树损失函数的定义。其中第一部分NtHt(T)N_tH_t(T)就是事物的不确定性次数。我们都知道存在噪声的情况下,模型将趋于复杂。在决策树中也就是|T|vert Tvert的数值越大。再来看看损失函数中的α|T|alphavert Tvert,假设参数αalpha已知的,那么复杂的模型,所带来的结果就是α|T|alphavert Tvert也将增大。且决策树存在过拟合现象,那么为了使得损失函数减小,有两种办法: 1. 降低第一部分的不确定次数,但我们知道这是不可能的了,因为降低不确定次数的办法是再找寻一个特征,或者找寻特征的最优切分点。这在生成决策时就已经决定了,无法改变。 2. 进行剪枝操作,这是可以的。剪枝最明显地变化就是叶结点个数变少。假设是一个三叉树,那么一次剪枝它的叶结点数减少2个。变化量为2α2alpha,有了这变化量,我们就可以用来求解最优决策子树了。

因为αalpha参数给定,所以一次剪枝,损失函数的减少量也是固定的。所有子结点都是三叉树时,我们可以简单认为损失函数的减少量为2α2alpha。假设只有一个子结点发生剪枝,那么该子结点上的叶结点都将全部归并到该结点,由此我们计算此结点的不确定次数。倘若不确定次数增大的量超过2α2alpha,那么剪枝失败,算法将尝试其他子结点。因为新得的子树损失函数反而增大。这从侧面又反映了什么样的事实?该子结点的分类规则是大大降低不确定次数的,并不存在噪声,所以没必要进行剪枝。所以在剪枝过程中,找寻的就是那些噪声点,却被过度规则那些子结点,把这些合并了,万事大吉,自然而然决策树的准确率将上升。

在上述剪枝过程中,还需要注意一个有趣的问题。对应于每一个参数αalpha,剪枝后的子树是唯一的嘛?个人觉得这是一个最基本的前提假设,在算法中,它提到了一点,给定参数αalpha,找寻损失函数最小的子树TαT_{alpha},也就是说<α,Tα><alpha,T_{alpha}>是一一对应的!并不存在一个αalpha对应于多个子树。CART剪枝算法中将用到该基本假设。

那么问题来了,参数αalpha给定的?谁来给?领域专家给?这是一种行之有效的办法,但却需要领域知识。理想化的模型都希望参数由data决定,也就是αalpha也由数据决定。那么我们能想到的就是拿测试数据去测试在给定αalpha下生成的子树。如果给定αalpha下,测试数据的准确率达到一定阈值我们就以这个αalpha为准。这就存在一个非常大的问题,αalpha如何变化,才能让测试数据准确率呈现极大值?这个问题在上述算法中并不好解决!

CART剪枝核心思想

刚才的思路是什么?从最宏观的角度去考虑的话,就是利用αalpha生成TαT_{alpha}。抽象一下,从【无限的实数】中找寻【有限个数的TαT_{alpha}】,这问题当然不好解决了!思路其实已经出来了,CART剪枝算法的核心思想就是说,一个复杂的决策树,不管多复杂,都能生成有限个数的子树,我们记作{T0,T1,...,Tn}{T_0,T_1,...,T_n},那么我们只要找寻到对应于每一个子树的αalpha不就可以了嘛!没错,抽象一下,从【有限个数的TαT_alpha】中找寻对应的【αalpha】,万事解决。

咱们先来看看决策树损失函数的定义:

Cα(T)=∑t=1|T|NtHt(T) α|T|

C_{alpha}(T) = sum_{t =1}^{vert Tvert}N_tH_t(T) alphavert Tvert 做一些数学变换得:

Cα(T)=∑t=1|T|NtHt(T) α|T|=∑t=1|T|(NtHt(T) α)

C_{alpha}(T) = sum_{t =1}^{vert Tvert}N_tH_t(T) alphavert Tvert = sum_{t=1}^{vert T vert}(N_tH_t(T) alpha) 所以说,衡量损失函数大小的真正贡献在于每个叶结点,叶结点不确定次数的累加并加个常数αalpha就是我们决策树整体的损失函数。

为了得到树T的所有子序列{T0,T1,...,Tn}{T_0,T_1,...,T_n},我们需要从底自上,每次只进行一次剪枝操作,那么进行剪枝操作后,该子序列应该是当前参数的最优子树。且应该是根据所剪的那个结点来计算参数αalpha。

为什么要这么做?接下来的思路是什么?因为我们刚才说了,是通过最优子树来求解参数αalpha,因此,我们先【假设】我们找到了当前的最优子树,且必然发生剪枝。【注意加黑的这句话!】具体地,需要归并的子结点为t,以t为单结点损失函数就可以表示为:(该子结点t已经成为我们的叶结点咯!在接下来给出的公式中,请思考哪些是已知参数,哪些是未知参数。)

Cα(t)=C(t) α

C_{alpha}(t) = C(t) alpha 这公式是最初的决策树损失函数变化而来的!它是其中一个子结点【吞并】它的子树,所得到【叶结点后】的损失函数。还需要强调下,因为在最初理解这个C(t)含义时,自己也被搞混了。该公式已经是剪枝完毕后的表达式了,也就是说原先的子结点已经变成了当前的叶结点。接下来会给剪枝前的表达式!C(t)C(t)表示在该叶结点中的不确定次数。

那么【剪枝前】是什么情况呢?剪枝前是以t为根结点的子树TtT_t的损失函数是:

Cα(Tt)=C(Tt) α|Tt|

C_{alpha}(T_t) = C(T_t) alphavert T_tvert 也就是说,剪枝前该子结点的损失函数如上。具体的含义之前都有解释过,就不再叙述了。接下来我们要明确哪些是已知参数,因为决策树已经生成了,所以每个结点的不确定次数都是知道的,即C(T),C(Tt)和|Tt|C_(T),C_(T_t)和vert T_t vert是已知的。未知参数是αalpha如何求解?还记得加粗的假设嘛?

假设1:必然发生剪枝!

从中我们便能求得αalpha,为什么?我们来观察下,上述两个式子。

当α=0alpha =0或者充分小,明显地,不确定次数:

Cα(Tt)<Cα(t)

C_{alpha}(T_t) lt C_{alpha}(t) 决策树叶结点越多,不确定性越低,不解释。

当增大αalpha时,总有那么一个点,能够使得:

Cα(Tt)=Cα(t)

C_{alpha}(T_t) = C_{alpha}(t)

当继续增大αalpha时,

Cα(Tt)>Cα(t)

C_{alpha}(T_t) gt C_{alpha}(t) 不等式反向,所以我们只要取α1=C(t)−C(Tt)|Tt|−1alpha_1 = frac{C(t)-C(T_t)}{vert T_tvert-1}时,当且仅当α≥α1alpha ge alpha_1时,剪枝必然发生。αalpha必须满足上述条件,否则前提假设将失去意义。所以说,我们通过假设剪枝必然发生就能找到对应的αalpha。可对于任何一个子结点t都可以嘛?别忘了第二个假设。

假设2:剪枝发生后,当前决策树是我们的最优子树

最优子树?现在t变成了一个变量,因为我们并不知道到底剪枝哪个子结点后决策树是最优的。不急,再来看看,公式:

Cα(t)=C(t) α

C_{alpha}(t) = C(t) alpha 剪枝已经发生,此时,对应于每一个子结点t会生成不同的αalpha我们记作α(t)alpha(t),由此得:

Cα(t)=C(t) α(t)

C_{alpha}(t) = C(t) alpha(t) 剪枝的决策树什么时候最优?对于当前参数α(t)alpha(t)而言,能够找到这样的t,使得

mint{C(t) α(t)}

min_{t}{C(t) alpha(t)} 然而在这里为了能够求得αalpha的一个序列,貌似直接最小化了

mint(C(t)−C(Tt)|Tt|−1)

min_{t}(frac{C(t)-C(T_t)}{vert T_tvert-1}) 找的αalpha即找到了子结点t,即完成了剪枝,即找到了最优子树T1T_1。有了上述的步骤,为了得到决策树T0T_0的所有子序列,直接递归下去,直到根节点即可。在这一过程中,不断地增加αalpha的值,产生新的区间。

后面的思路就很简单了,根据生成的子树序列,用测试集去测试这些子树,谁的测试准确率最高,谁就获胜。

算法(CART剪枝算法)

输入:CART算法生成的决策树T0T_0 输出:最优决策树TαT_{alpha} (1)设k=0,T=T0k =0,T =T_0 (2)设α= ∞alpha = infty (3)自下而上地对各个内部结点t计算C(Tt),|Tt|C(T_t),vert T_tvert以及 α(t)=C(t)−C(Tt)|Tt|−1 alpha(t) =frac{C(t)-C(T_t)}{vert T_tvert-1} α=min(α,α(t)) alpha = min (alpha,alpha(t)) 这里,TtT_t表示t为根结点的子树,C(Tt)C(T_t)是对训练数据的预测误差,|Tt|vert T_t vert是TtT_t的叶结点个数。 (4)对α(t)=αalpha(t) = alpha 的内部结点t进行剪枝,并对叶结点t以多数表决法决定其类,得到树T。 (5)设k=k 1,αk=α,Tk=Tk = k 1,alpha_k = alpha,T_k =T. (6)如果TkT_k不是由根结点及两个叶结点构成的树,则回到步骤3;否则令Tk=TnT_k = T_n。 采用交叉验证法在子树序列T0,T1,...,TnT_0,T_1,...,T_n中选取最优子树TαT_{alpha}

总的来说,CART剪枝算法的核心在于用【有限个子树TαT_{alpha}】计算【无限个αalpha】,你会发现,计算过程中αalpha变成了一个个分段区间。在利用公式推导时,注意【必剪枝】和【当前最优子树】的两个假设,且时刻问自己【已知参数】和【未知参数】是哪些。


CART剪枝算法实现

原本想亲自实现一把CART的剪枝算法,但发现在现有代码的基础上,实现它需要比较好的python基础,然而本人在代码上的造诣尚浅,先占个位,待日后补上剪枝算法实现。

参考文献:

1. 决策树之理解ID3算法和C4.5算法。 2.李航. 统计学习方法[M]. 北京:清华大学出版社,2012

0 人点赞