⚠️ Alpha内测版本警告:此为早期内部构建版本,尚不完整且可能存在错误,欢迎大家提Issue反馈问题或建议。
Skip to content

第5章 决策树

习题5.1

  根据表5.1所给的训练数据集,利用信息增益比(C4.5算法)生成决策树。

解答:

表5.1 贷款申请样本数据表

ID年龄有工作有自己的房子信贷情况类别
1青年一般
2青年
3青年
4青年一般
5青年一般
6中年一般
7中年
8中年
9中年非常好
10中年非常好
11老年非常好
12老年
13老年
14老年非常好
15老年一般

解答思路:

  1. 列出C4.5的生成算法;
  2. 使用sklearn的DecisionTreeClassifier类构建决策树,并使用graphviz包展示,默认是Gini,这里可以作为自编程的验证
  3. 通过自编程实现C4.5算法生成决策树,并进行特征选择

解题步骤:

第1步:列出C4.5的生成算法

根据书中第5.3.2节C4.5的生成算法:

算法5.3 C4.5的生成算法 输入:训练数据集D,特征集A阈值ϵ
输出:决策树T
(1)如果D中所有实例属于同一类Ck,则置T为单结点树,并将Ck作为该结点的类,返回T
(2)如果A=,则置T为单结点树,并将D中实例数最大的类Ck作为该结点的类,返回T
(3)否则,按式gR(D,A)=g(D,A)HA(D)计算A中各特征对D的信息增益比,选择信息增益比最大的特征Ag
(4)如果Ag的信息增益比小于阈值ϵ,则置T为单结点树,并将D中实例数最大的类Ck作为该结点的类,返回T
(5)否则,对Ag的每一可能值ai,依Ag=aiD分割为子集若干非空Di,将Di中实例数最大的类作为标记,构建子结点,由结点及其子结点构成树T,返回T
(6)对结点i,以Di为训练集,以A{Ag}为特征集,递归地调用步(1)~步(5),得到子树Ti,返回Ti

第2步:调用sklearn的DecisionTreeClassifier类构建决策树

python
from sklearn.tree import DecisionTreeClassifier
from sklearn import preprocessing
import numpy as np
import pandas as pd

from sklearn import tree
import graphviz

features = ["年龄", "有工作", "有自己的房子", "信贷情况"]
X_train = pd.DataFrame([
    ["青年", "否", "否", "一般"],
    ["青年", "否", "否", "好"],
    ["青年", "是", "否", "好"],
    ["青年", "是", "是", "一般"],
    ["青年", "否", "否", "一般"],
    ["中年", "否", "否", "一般"],
    ["中年", "否", "否", "好"],
    ["中年", "是", "是", "好"],
    ["中年", "否", "是", "非常好"],
    ["中年", "否", "是", "非常好"],
    ["老年", "否", "是", "非常好"],
    ["老年", "否", "是", "好"],
    ["老年", "是", "否", "好"],
    ["老年", "是", "否", "非常好"],
    ["老年", "否", "否", "一般"]
])
y_train = pd.DataFrame(["否", "否", "是", "是", "否",
                        "否", "否", "是", "是", "是",
                        "是", "是", "是", "是", "否"])
class_names = [str(k) for k in np.unique(y_train)]
# 数据预处理
le_x = preprocessing.LabelEncoder()
le_x.fit(np.unique(X_train))
X_train = X_train.apply(le_x.transform)
# 调用sklearn的DecisionTreeClassifier建立决策树模型
model_tree = DecisionTreeClassifier()
# 训练模型
model_tree.fit(X_train, y_train)

# 导出决策树的可视化文件,文件格式是dot
dot_data = tree.export_graphviz(model_tree, out_file=None,
                                feature_names=features,
                                class_names=class_names,
                                filled=True, rounded=True,
                                special_characters=True)
# 使用graphviz包,对决策树进行展示
graph = graphviz.Source(dot_data)
# 可使用view方法展示决策树
# 中文乱码:需要对源码_export.py文件(文件路径:sklearn/tree/_export.py)修改,
# 在文件第451行中将helvetica改成SimSun
graph

svg

python
# 打印决策树
tree_text = tree.export_text(model_tree, feature_names=features)
print(tree_text)
|--- 有自己的房子 <= 3.00
|   |--- 有工作 <= 3.00
|   |   |--- class: 否
|   |--- 有工作 >  3.00
|   |   |--- class: 是
|--- 有自己的房子 >  3.00
|   |--- class: 是

第3步:自编程实现C4.5算法生成决策树

python
import json
from collections import Counter

import numpy as np


# 节点类
class Node:
    def __init__(self, node_type, class_name, feature_name=None,
                 info_gain_ratio_value=0.0):
        # 结点类型(internal或leaf)
        self.node_type = node_type
        # 特征名
        self.feature_name = feature_name
        # 类别名
        self.class_name = class_name
        # 子结点树
        self.child_nodes = []
        # Gini指数值
        self.info_gain_ratio_value = info_gain_ratio_value

    def __repr__(self):
        return json.dumps(self, indent=3, default=lambda obj: obj.__dict__, ensure_ascii=False)

    def add_sub_tree(self, key, sub_tree):
        self.child_nodes.append({"condition": key, "sub_tree": sub_tree})
python
class MyDecisionTree:
    def __init__(self, epsilon):
        self.epsilon = epsilon
        self.tree = None

    def fit(self, train_set, y, feature_names):
        features_indices = list(range(len(feature_names)))
        self.tree = self._fit(train_set, y, features_indices, feature_names)
        return self

    # C4.5算法
    def _fit(self, train_data, y, features_indices, feature_labels):
        LEAF = 'leaf'
        INTERNAL = 'internal'
        class_num = len(np.unique(y))

        # (1)如果训练数据集所有实例都属于同一类Ck
        label_set = set(y)
        if len(label_set) == 1:
            # 将Ck作为该结点的类
            return Node(node_type=LEAF, class_name=label_set.pop())

        # (2)如果特征集为空
        # 计算每一个类出现的个数
        class_len = Counter(y).most_common()
        (max_class, max_len) = class_len[0]

        if len(features_indices) == 0:
            # 将实例数最大的类Ck作为该结点的类
            return Node(LEAF, class_name=max_class)

        # (3)按式(5.10)计算信息增益,并选择信息增益最大的特征
        max_feature = 0
        max_gda = 0
        D = y.copy()
        # 计算特征集A中各特征
        for feature in features_indices:
            # 选择训练集中的第feature列(即第feature个特征)
            A = np.array(train_data[:, feature].flat)
            # 计算信息增益
            gda = self._calc_ent_grap(A, D)
            if self._calc_ent(D) != 0:
                # 计算信息增益比
                gda /= self._calc_ent(D)
            # 选择信息增益最大的特征Ag
            if gda > max_gda:
                max_gda, max_feature = gda, feature

        # (4)如果Ag信息增益小于阈值
        if max_gda < self.epsilon:
            # 将训练集中实例数最大的类Ck作为该结点的类
            return Node(LEAF, class_name=max_class)

        max_feature_label = feature_labels[max_feature]

        # (6)移除已选特征Ag
        sub_feature_indecs = np.setdiff1d(features_indices, max_feature)
        sub_feature_labels = np.setdiff1d(feature_labels, max_feature_label)

        # (5)构建非空子集
        # 构建结点
        feature_name = feature_labels[max_feature]
        tree = Node(INTERNAL, class_name=None, feature_name=feature_name,
                    info_gain_ratio_value=max_gda)

        max_feature_col = np.array(train_data[:, max_feature].flat)
        # 将类按照对应的实例数递减顺序排列
        feature_value_list = [x[0] for x in Counter(max_feature_col).most_common()]
        # 遍历Ag的每一个可能值ai
        for feature_value in feature_value_list:
            index = []
            for i in range(len(y)):
                if train_data[i][max_feature] == feature_value:
                    index.append(i)

            # 递归调用步(1)~步(5),得到子树
            sub_train_set = train_data[index]
            sub_train_label = y[index]
            sub_tree = self._fit(sub_train_set, sub_train_label, sub_feature_indecs, sub_feature_labels)
            # 在结点中,添加其子结点构成的树
            tree.add_sub_tree(feature_value, sub_tree)

        return tree

    # 计算数据集x的经验熵H(x)
    def _calc_ent(self, x):
        x_value_list = set([x[i] for i in range(x.shape[0])])
        ent = 0.0
        for x_value in x_value_list:
            p = float(x[x == x_value].shape[0]) / x.shape[0]
            logp = np.log2(p)
            ent -= p * logp

        return ent

    # 计算条件熵H(y/x)
    def _calc_condition_ent(self, x, y):
        x_value_list = set([x[i] for i in range(x.shape[0])])
        ent = 0.0
        for x_value in x_value_list:
            sub_y = y[x == x_value]
            temp_ent = self._calc_ent(sub_y)
            ent += (float(sub_y.shape[0]) / y.shape[0]) * temp_ent

        return ent

    # 计算信息增益
    def _calc_ent_grap(self, x, y):
        base_ent = self._calc_ent(y)
        condition_ent = self._calc_condition_ent(x, y)
        ent_grap = base_ent - condition_ent

        return ent_grap

    def __repr__(self):
        return str(self.tree)
python
# 表5.1的训练数据集
feature_names = np.array(["年龄", "有工作", "有自己的房子", "信贷情况"])
X_train = np.array([
    ["青年", "否", "否", "一般"],
    ["青年", "否", "否", "好"],
    ["青年", "是", "否", "好"],
    ["青年", "是", "是", "一般"],
    ["青年", "否", "否", "一般"],
    ["中年", "否", "否", "一般"],
    ["中年", "否", "否", "好"],
    ["中年", "是", "是", "好"],
    ["中年", "否", "是", "非常好"],
    ["中年", "否", "是", "非常好"],
    ["老年", "否", "是", "非常好"],
    ["老年", "否", "是", "好"],
    ["老年", "是", "否", "好"],
    ["老年", "是", "否", "非常好"],
    ["老年", "否", "否", "一般"]
])
y = np.array(["否", "否", "是", "是", "否",
              "否", "否", "是", "是", "是",
              "是", "是", "是", "是", "否"])

dt_tree = MyDecisionTree(epsilon=0.1)
dt_tree.fit(X_train, y, feature_names)
dt_tree
{
   "node_type": "internal",
   "feature_name": "有自己的房子",
   "class_name": null,
   "child_nodes": [
      {
         "condition": "否",
         "sub_tree": {
            "node_type": "internal",
            "feature_name": "年龄",
            "class_name": null,
            "child_nodes": [
               {
                  "condition": "否",
                  "sub_tree": {
                     "node_type": "leaf",
                     "feature_name": null,
                     "class_name": "否",
                     "child_nodes": [],
                     "info_gain_ratio_value": 0.0
                  }
               },
               {
                  "condition": "是",
                  "sub_tree": {
                     "node_type": "leaf",
                     "feature_name": null,
                     "class_name": "是",
                     "child_nodes": [],
                     "info_gain_ratio_value": 0.0
                  }
               }
            ],
            "info_gain_ratio_value": 1.0
         }
      },
      {
         "condition": "是",
         "sub_tree": {
            "node_type": "leaf",
            "feature_name": null,
            "class_name": "是",
            "child_nodes": [],
            "info_gain_ratio_value": 0.0
         }
      }
   ],
   "info_gain_ratio_value": 0.4325380677663126
}

习题5.2

  已知如表5.2所示的训练数据,试用平方误差损失准则生成一个二叉回归树。
表5.2 训练数据表

xi12345678910
yi4.504.754.915.345.807.057.908.238.709.00

解答:

解答思路:

  1. 根据书中第5.5.1节平方误差最小的准则,列出最小二乘回归树生成算法(算法5.5);
  2. 编写代码,实现算法,并用表5.2训练数据进行验证。

解题步骤:

第1步:算法5.5的最小二乘回归树生成算法

根据书中第5.5.1节的算法5.5:

  决策树的生成就是递归地构建二叉决策树的过程,对回归树用平方误差最小化准则,对分类树用基尼指数(Gini index)最小化准则,进行特征选择,生成二叉树。

算法5.5(最小二乘回归树生成算法)
输入:训练数据集D
输出:回归树f(x)
在训练数据集所在的输入空间中,递归地将每个区域划分为两个子区域并决定每个子区域上的输出值,构建二叉决策树;
(1)选择最优切分变量j与切分点s,求解

minj,s[minc1xiR1(j,s)(yic1)2+minc2xiR2(j,s)(yic2)2]

遍历变量j,对固定的切分变量j扫描切分点s,选择使得上式达到最小值的对(j,s)
(2)用选定的对(j,s)划分区域并决定相应的输出值:

R1(j,s)={x|x(j)s},R2(j,s)={x|x(j)>s}c^m=1NmxiRm(j,s)yi,xRm,m=1,2

(3)继续对两个子区域调用步骤(1),(2),直至满足停止条件
(4)将输入空间划分为M个区域R1,R2,,RM,生成决策树:

f(x)=m=1Mc^mI(xRm)

第2步:编写代码,实现算法,并用表5.2训练数据进行验证

python
import json

import numpy as np


# 节点类
class Node:
    def __init__(self, value, feature, left=None, right=None):
        self.value = value.tolist()
        self.feature = feature.tolist()
        self.left = left
        self.right = right

    def __repr__(self):
        return json.dumps(self, indent=3, default=lambda obj: obj.__dict__, ensure_ascii=False)
python
class MyLeastSquareRegTree:
    def __init__(self, train_X, y, epsilon):
        # 训练集特征值
        self.x = train_X
        # 类别
        self.y = y
        # 特征总数
        self.feature_count = train_X.shape[1]
        # 损失阈值
        self.epsilon = epsilon
        # 回归树
        self.tree = None

    def _fit(self, x, y, feature_count):
        # (1)选择最优切分点变量j与切分点s,得到选定的对(j,s),并解得c1,c2
        (j, s, minval, c1, c2) = self._divide(x, y, feature_count)
        # 初始化树
        tree = Node(feature=j, value=x[s, j], left=None, right=None)
        # 用选定的对(j,s)划分区域,并确定响应的输出值
        if minval < self.epsilon or len(y[np.where(x[:, j] <= x[s, j])]) <= 1:
            tree.left = c1
        else:
            # 对左子区域调用步骤(1)、(2)
            tree.left = self._fit(x[np.where(x[:, j] <= x[s, j])],
                                  y[np.where(x[:, j] <= x[s, j])],
                                  self.feature_count)
        if minval < self.epsilon or len(y[np.where(x[:, j] > x[s, j])]) <= 1:
            tree.right = c2
        else:
            # 对右子区域调用步骤(1)、(2)
            tree.right = self._fit(x[np.where(x[:, j] > x[s, j])],
                                   y[np.where(x[:, j] > x[s, j])],
                                   self.feature_count)
        return tree

    def fit(self):
        self.tree = self._fit(self.x, self.y, self.feature_count)
        return self

    @staticmethod
    def _divide(x, y, feature_count):
        # 初始化损失误差
        cost = np.zeros((feature_count, len(x)))
        # 公式5.21
        for i in range(feature_count):
            for k in range(len(x)):
                # k行i列的特征值
                value = x[k, i]
                y1 = y[np.where(x[:, i] <= value)]
                c1 = np.mean(y1)
                y2 = y[np.where(x[:, i] > value)]
                if len(y2) == 0:
                    c2 = 0
                else:
                    c2 = np.mean(y2)
                y1[:] = y1[:] - c1
                y2[:] = y2[:] - c2
                cost[i, k] = np.sum(y1 * y1) + np.sum(y2 * y2)
        # 选取最优损失误差点
        cost_index = np.where(cost == np.min(cost))
        # 所选取的特征
        j = cost_index[0][0]
        # 选取特征的切分点
        s = cost_index[1][0]
        # 求两个区域的均值c1,c2
        c1 = np.mean(y[np.where(x[:, j] <= x[s, j])])
        c2 = np.mean(y[np.where(x[:, j] > x[s, j])])
        return j, s, cost[cost_index], c1, c2

    def __repr__(self):
        return str(self.tree)
python
train_X = np.array([[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]).T
y = np.array([4.50, 4.75, 4.91, 5.34, 5.80, 7.05, 7.90, 8.23, 8.70, 9.00])

model_tree = MyLeastSquareRegTree(train_X, y, epsilon=0.2)
model_tree.fit()
model_tree
{
   "value": 5,
   "feature": 0,
   "left": {
      "value": 3,
      "feature": 0,
      "left": 4.72,
      "right": 5.57
   },
   "right": {
      "value": 7,
      "feature": 0,
      "left": {
         "value": 6,
         "feature": 0,
         "left": 7.05,
         "right": 7.9
      },
      "right": {
         "value": 8,
         "feature": 0,
         "left": 8.23,
         "right": 8.85
      }
   }
}

根据上面程序的输出,可得到用平方误差损失准则生成一个二叉回归树:$$f(x)=\begin{cases} 4.72 & x \le 3\ 5.57 & 3 < x \le 5\ 7.05 & 5 < x \le 6\ 7.9 & 6 < x \le 7 \ 8.23 & 7 < x \le 8\ 8.85 & x > 8\ \end{cases}$$

习题5.3

  证明 CART 剪枝算法中,当α确定时,存在唯一的最小子树Tα使损失函数Cα(T)最小。

解答:

解答思路:

  1. 列出CART剪枝算法;
  2. 列出树的剪枝算法;
  3. 采用反证法,假设存在两棵子树使得损失函数最小。

解答步骤:

第1步:算法5.7的CART剪枝算法

根据书中的算法5.7:

算法5.7(CART剪枝算法)
输入:CART算法生成的决策树T0
输出:最优决策树Tα
(1)设k=0,T=T0
(2)设α=+
(3)自下而上地对各内部结点t计算C(Tt),|Tt|,以及

g(t)=C(t)C(Tt)|Tt|1α=min(α,g(t))

这里,Tt表示以t为根结点的子树,C(Tt)是对训练数据的预测误差,|Tt|Tt的叶结点个数。
(4)对g(t)=α的内部结点t进行剪枝,并对叶结点t以多数表决法决定其类,得到树T
(5)设k=k+1,αk=α,Tk=T
(6)如果Tk不是由根节点和两个叶结点组成的树,则回到步骤(2),否则Tk=Tn
(7)采用交叉验证法在子树序列从T0,T1,,Tn中选取最优子树Tα

第2步:算法5.4的树的剪枝算法

根据书中的算法5.4:

算法5.4(树的剪枝算法)
输入:生成算法产生的整个树T,参数α
输出:修剪后的子树Tα
(1)计算每个结点的经验熵。
(2)递归地从树的叶结点向上回缩。
  设一组叶结点回缩到其父结点之前与之后的整体树分别为TBTA,其对应的损失函数分别是Cα(TB)Cα(TA),如果

Cα(TA)Cα(TB)

则进行剪枝,即将父结点变为新的叶结点。
(3)返回(2),直至不能继续为止,得到损失函数最小的子树Tα

第3步:采用反证法,假设存在两棵子树使得损失函数最小

  从第1、2步可得到以下结论:

  1. 内部节点是否剪枝只与以该节点为根节点的子树有关;
  2. Cα(t)<Cα(Tt)时,对以结点t为根节点的子树进行剪枝。


  如图5.1所示,整个树T1有两个子树T2T3,其剪枝位置分别为t2t3,假设两棵子树都是T1的最优子树,使得损失函数最小。则满足以下等式:

Cα(T2)=Cα(T3)

  根据子树T2的剪枝位置是t2,可得

Cα(t2)<Cα(T2)

  根据子树T3的剪枝位置是t3,可得

Cα(t3)<Cα(T3)

  由上面公式可得:

Cα(t2)<Cα(T3)Cα(t3)<Cα(T2)

  根据上述公式,可知T2T3都可以进一步剪枝,剪枝之后的子树记作T4


  因此,如果存在两棵以上的最优子树,其中一棵树总能找到一个来自另一棵子树的剪枝点,使得整体损失进一步下降,所以只能存在唯一的最小子树使得损失函数最小,得证。

习题5.4

  证明 CART 剪枝算法中求出的子树序列{T0,T1,,Tn}分别是区间α[αi,αi+1)的最优子树Tα,这里i=0,1,,n,0=α0<α1<,αn<+

解答:

解答思路:

  1. 对题目重新进行表述;
  2. 证明当α=0α=+时的最优子树;
  3. 证明T1为区间[α1,α2)的最优子树。

解答步骤:

第1步:对题目重新表述

  原结论可以表述为:将α0增大到+(即0=α0<α1<<αn<+),在每个区间[αi,αi+1)中,其中i=0,1,,n,子树Ti是该区间里最优的。

第2步:证明当α=0α=+时的最优子树

根据书中第5.5.2节的剪枝描述:

  当α大的时候,最优子树Tα偏小;当α小的时候,最优子树Tα偏大。极端情况,当α=0时,整体树是最优的。当α+时,根结点组成的单结点树是最优的。

第3步:证明T1是区间[α1,α2)的最优子树

  根据《Classification and Regression Trees》书中第10.2节的定理10.7:

THEOREM 10.7. Every tree T has a unique smallest optimally pruned subtree T(α). Let T be a nontrivial tree having root t1 and primary branches TL and TR. Then $$R_{\alpha}(T(\alpha)) = \min[R_{\alpha}(t_1), R_{\alpha}(T_L(\alpha))+R_{\alpha}(T_R(\alpha))]$$ If Rα(t1)Rα(TL(α))+Rα(TR(α)), then T(α)={t1}; otherwise, T(α)={t1}TL(α)TR(α).

  根据该书的符号描述,其中:

  1. T(α)表示,在α条件时,树T的最小最优剪枝子树
  2. Rα(T(α))表示,在α条件时,T(α)的损失值,即Cα(Tα)

  定理10.7表示,树T分为根结点t1、左子树TL和右子树TR,计算在α条件时的最优子树的损失,可取Rα(t1)Rα(TL(α))+Rα(TR(α))中的最小值,并且T(α)满足以下公式:

T(α)={{t1},Rα(t1)Rα(TL(α))+Rα(TR(α)){t1}TL(α)TR(α),otherwise

  根据定理10.7,可以得到该书定理10.9的第一个推论:

THEOREM 10.9.
(i) If α2α1, then T(α2)T(α1).

  定理10.9(i)表示,当α2α1时,树Tα2条件下的最优子树一定是树Tα1条件下的最优子树的子树。

  根据CART剪枝算法和定理10.9,可知(见该书的第368页):

  1. αα1时,T0(α)=T1
  2. α1α<α2时,$T_0(\alpha) \preceq T_0(\alpha_1) = T_1 \preceq T_0 $

  所以,T0(α)=T1(α)=T1,该式说明T1是区间[α1,α2)的最优子树。

  依次类推,子树Ti是区间[αi,αi+1)里最优的,原结论得证。

参考文献

【1】Friedman. Classification and Regression Trees[M]. 1984:364