03 March 2018

决策树是一个树状结构决策图,根据对象属性用概率统计的方式预测结果。

决策树的难点是在已有数据样本的情况下如何生成决策树,一旦生成之后就是根据属性做 if-else 判断出结果即可。

本文使用 scikit-learn 中的 iris 的鸢尾花数据集作为例子来学习决策树算法。

鸢尾花数据集是包含150个样本的三种不同种类的鸢尾花,每个鸢尾花有4个属性特征。

四个特征:花萼长度(cm),花萼宽度(cm),花瓣长度(cm),花瓣宽度(cm)

三种鸢尾花:山鸢尾花(Iris Setosa), 变色鸢尾花(Iris Versicolor), 维吉尼亚鸢尾花(Iris Virginica)

关于鸢尾花数据集的详细介绍参考这篇文章:探索sklearn 鸢尾花数据集

%matplotlib notebook
from sklearn.datasets import load_iris
import numpy as np
import matplotlib.pyplot as plt

# 加载数据集
iris = load_iris()

# 数据特征:150行, 4列
features = iris['data']

# 对应的鸢尾花种类: 150个,三种鸢尾花分别用 0,1,2 表示
target = iris['target']


# 4个特征的名称
feature_names = iris.feature_names
# ['sepal length (cm)', 'sepal width (cm)', 'petal length (cm)', 'petal width (cm)']
#   花萼长度,花萼宽度,花瓣长度,花瓣宽度
feature_names = ['花萼长度', '花萼宽度', '花瓣长度', '花瓣宽度']

# 三种鸢尾花的名称
class_names = iris.target_names
# ['setosa', 'versicolor', 'virginica']
# 山鸢尾花, 变色鸢尾花, 维吉尼亚鸢尾花
class_names = ['山鸢尾花', '变色鸢尾花', '维吉尼亚鸢尾花']

在使用上述的鸢尾花数据样本生成决策树之前,我们需要先了解几个概念。

信息熵

在信息论中,熵是接收的每条消息中包含的信息的平均量,又称为信息熵。离散随机变量的熵 H 定义为:

熵表示信息的不确定性,熵越大知道的信息就越少。关于熵的理论这里不做过多介绍,我们只通过例子来加深理解。

对于鸢尾花来说,随机抽出一个鸢尾花,它到底属于哪一种鸢尾花就是不确定的。

我们通过数据集可知道三种鸢尾花的数量,每种鸢尾花的概率就是数量除以总数(这里总数是 150)。

# 三种鸢尾花数量
iris_count = np.zeros(3)

iris_count[0] = target[target == 0].size
iris_count[1] = target[target == 1].size
iris_count[2] = target[target == 2].size

print("三种鸢尾花的数量分别为:", iris_count)

# 计算每种鸢尾花的概率
iris_probability = np.divide(iris_count, 150)

print("三种莺尾花的概率为:", iris_probability)

# 根据公式算出鸢尾花的熵:
iris_h = -np.sum(iris_probability * np.log2(iris_probability))

print("鸢尾花的熵为:", iris_h)

三种鸢尾花的数量分别为: [50. 50. 50.]
三种莺尾花的概率为: [0.33333333 0.33333333 0.33333333]
鸢尾花的熵为: 1.584962500721156

在信息论中,熵的单位可以用 bit 表示。从上述结果可以看出,三种鸢尾花同等概率,熵为 1.5 bit。这跟我们程序设计时需要用多少位表示多少种状态的理论是类似的。

根据信息熵的数学公式,我们可以知道,所有概率 $p_i(x)$ 都一样时熵(不确定性)最大。通俗点说就是,所有种类概率相等时,我们很难猜出一朵鸢尾花到底是哪一种。如果有一种鸢尾花的概率是百分之九十的话,我们不需要其他信息就可以猜出它属于那种花(不确定性相对较小)。

为了降低信息熵,我们需要引入更多的信息。如果我们知道一朵鸢尾花的属性的话,我们就能更好的猜测出来它的种类(即不确定性降低了,熵减少了)。决策树通过引入鸢尾花的属性(特征)的信息来降低信息熵。为了更好的识别出鸢尾花特征和种类之间的关系,我们先通过以下代码画出鸢尾花的四个特征分别与鸢尾花种类的散点图。

colors='rgby'

# 设置图像大小及dpi
# plt.rcParams["figure.figsize"] = [6, 4]
# plt.rcParams['figure.dpi'] = 100

# 生成散点图
for i in range(4):
    plt.subplot(2, 2, i+1)
    plt.scatter(features[:,i], target, c=colors[i])
    plt.xlabel(feature_names[i])
    plt.ylabel('花的种类')

plt.suptitle("特征和鸢尾花种类散点图")
# plt.legend(loc='lower right', borderpad=0)
plt.tight_layout(pad=3, w_pad=2, h_pad=2)
plt.show()

通过上述散点图可以看出,通过花瓣的宽度和花瓣的长度可以较好的识别出山鸢尾花(值为0)。也就是说如果我们知道鸢尾花的花瓣长度的话,我们再猜测它所属的种类的话,猜对的概率就会大很多。因此引入了花瓣长度,可以降低鸢尾花的熵(即条件熵)。

条件熵

在已知随机变量A(鸢尾花的属性)的情况下,随机变量X(鸢尾花的种类)的熵。条件熵与信息熵的理论意义是相同的,都表示信息的不确定性。只是数学公式稍微有点差别,其数学公式如下:

其中 n 表示属性A的分类数目,$a_i$ 表示具体的分类值, $p_i$ 表示具体分类的占比, $H(X|A=a_i)$ 表示属于A为一个具体值是的信息熵。

以鸢尾花的花瓣宽度为例的话,我们以 0.8cm 为分界线,把鸢尾花花瓣宽度分为两类(即 n = 2)。样本中花瓣宽度小于 0.8 (为什么选这个值呢,后续介绍)的有 50 个,大于 0.8 的有 100 个,所以 $p_0 = 50/150 = 0.3333$, $p_1 = 100 / 150 = 0.6666$ 。 $H(X|A=a_i)$ 的公式跟上面介绍的熵的计算公式是一样的,只不过每次X的样本被分为两个,分别计算这两个鸢尾花的熵。我们通过以下代码计算条件熵。

# 定义计算熵公式的函数
def calcEntropy(target):
    label = np.unique(target)
    n = label.size
    count = np.zeros(n)
    p_i = np.zeros(n)
    for i in range(n):
        count[i] = target[target == label[i]].size
    
    # 计算每个类别的概率
    p_i = np.divide(count, target.size)
    
    # 计算熵
    entropy = 0
    for i in range(n):
        entropy = entropy - p_i[i] * np.log2(p_i[i])
    
    return entropy


# 定义二分类条件熵公式的函数,把属性的值分为两类
# 对于花瓣宽度来说: 一类是大于 0.8, 另一类就是小于等于 0.8
def calcConditionEntropy(feature, condition, target):
    true_condition = condition(feature)
    false_condition = true_condition == False
    target_true = target[true_condition]
    target_false = target[false_condition]
    # 每种属性类别的数量除以总数就计算出其概率
    p_true = target_true.size / target.size
    p_false = 1 - p_true
    # 每种属性类别的概率乘以该类别下的信息熵
    entropy = p_true * calcEntropy(target_true) + p_false * calcEntropy(target_false)
    return entropy


# 重新使用函数计算一次鸢尾花的熵,结果跟上面一样
H = calcEntropy(target)

# 加入鸢尾花花瓣宽度属性后,计算鸢尾花的条件熵
petal_width = features[:,3]
HC = calcConditionEntropy(petal_width, lambda feature: feature < 0.8, target)
print('鸢尾花默认的信息熵 :', H)
print('带花瓣宽度的条件熵 :', HC)
鸢尾花默认的信息熵 : 1.584962500721156
带花瓣宽度的条件熵 : 0.6666666666666667

上述结果可以看出,条件熵的值比默认的信息熵更低一些,即不确定性更少了。由此引入一个概念:信息增益。

信息增益

信息增益表示不确定性减少的程度,其值等于信息熵减去条件熵。

决策树的生成算法 ID3 就是根据信息增益的大小来选择节点的特征,用递归方法构建决策树的:

  • 遍历所有特征的信息增益,选择信息增益最大的特征作为根节点的特征
  • 由节点特征的不同分类建立子节点,再对各个子节点上述方法选择各子节点的特征
  • 重复以上步骤直至所有特征的信息增益都很小或者没有特征可以选择为止

对于特征已经是二分类(例如,下雨/不下雨)的情况下,可以比较方便的计算出各特征的信息增益。

本文中鸢尾花的特征数据虽然是离散的,但是取值数目很多,需要进一步分类才能使用。

特征分类方法

当特征的取值数目比较多是,需要根据如下方法进行归类:

  • 对特征的取值进行排序(feature 和 target 需要同步排序)
  • 将 target 有变动的地方作为可能的划分点(比例,target取值从0变成1)
  • 分界点定义为划分点的对应的两个特征取值的平均值
  • 计算所有划分点的信息增益,选取最大的那个信息增益对应的值作为最终选择的分界点

还记得上面我们讲到的 0.8 是怎么来的么?没错,就是通过特征分类方法选择出来的分界点。

现在我们通过代码计算鸢尾花花瓣宽度的分界点。

def generate_feature_points(feature, target):
    """
    生成特征的所有分界点: 先对特征进行排序,然后将 target 有变动的地方作为分界点
    :param feature: 一维数组,一个特征的样本数据
    :param target: 一维数组,数字或者字符串的分类标签
    :return: 包含所有分界点的一维数组
    """

    argsort = feature.argsort()
    f1 = feature[argsort]
    t1 = target[argsort]

    last_value = target[0]
    split_value = []

    # 找出所有分裂点
    for i in range(t1.size):
        if last_value != t1[i]:
            split_value.append((f1[i] + f1[i - 1]) / 2)
            last_value = t1[i]

    return np.array(split_value)


def calc_feature_entropy(feature, target):
    """
    计算一个特征的所有分界点的条件熵,返回最小的那个条件熵(条件熵越小,信息增益越大)
    :param feature: 一维数组,一个特征的样本数据
    :param target: 一维数组,数字或者字符串的分类标签
    :return: 分界点和条件熵
    """
    min_entropy = float('inf')
    min_point = 0
    points = generate_feature_points(feature, target)
    for p in points:
        entropy = calcConditionEntropy(feature, lambda f: f < p, target)
        if entropy < min_entropy:
            min_entropy = entropy
            min_point = p

    '没有分界点说明只有一类数据标签,熵为0'
    if points.size == 0:
        min_entropy = 0

    return min_point, min_entropy

calc_feature_entropy(features[:,3], target)
(0.8, 0.6666666666666667)

从上述结果可以看出 ,花瓣宽度的分界点为 0.8 时熵最小,此时的该特征的信息增益时最大的。下面我们来完成根据 ID3 算法创建决策树的代码。

def select_feature(features, target):
    """
    从所有特征中选择出条件熵最小的特征(即最大增益)
    :param features: 二维数据,包含所有特征的样本数据
    :param target: 一维数组,数字或者字符串的分类标签
    :return: 特征索引,条件熵,特征分界点
    """
    min_entropy = float('inf')
    min_point = 0
    num = features.shape[1]
    index = 0
    for i in range(num):
        point, entropy = calc_feature_entropy(features[:, i], target)
        if entropy <= min_entropy:
            index = i
            min_point = point
            min_entropy = entropy

    return index, min_point, min_entropy

class TreeNode:
    idn = 0
    feature_index = ''
    feature_point = 0
    feature_entropy = 0
    target_label = ''
    true_node = None
    false_node = None

    @staticmethod
    def decision(feature, point):
        return feature < point


def build_tree(features, target, idn):
    """
    递归构建决策树
    :param features: 二维数据,包含所有特征的样本数据
    :param target: 一维数组,数字或者字符串的分类标签
    :param idn: 决策树节点 id,通过 id 观察决策树计算过程
    :return: 决策树根节点
    """
    node = TreeNode()

    '选择条件熵最小的特征'
    index, point, entropy = select_feature(features, target)
    node.idn = idn
    node.feature_index = index
    node.feature_point = point
    node.feature_entropy = entropy
    '取出现次数最多的标签作为该特征节点的输出'
    node.target_label = target[np.argmax(np.bincount(target))]

    print('build tree node id %d, index %d, point %f, entropy %f, label %s ' %
          (idn, index, point, entropy, node.target_label))

    '熵小于 0.1 时则结束创建子节点,防止过拟合'
    if entropy < 0.1:
        print('too low entropy : ', entropy)
        return node

    f_copy = features.copy()
    t_copy = target.copy()
    f = f_copy[:, index]
    selector = node.decision(f, point)

    '创建左右两个子节点'
    idn = idn + 1
    node.true_node = build_tree(f_copy[selector, :], t_copy[selector], idn)
    idn = node.true_node.idn + 1
    node.false_node = build_tree(f_copy[selector == False], t_copy[selector == False], idn)
    return node

'构建决策树,节点编号从 1 开始,深度优先递归方式创建树'
build_tree(features, target, 1)
build tree node id 1, index 3, point 0.800000, entropy 0.666667, label 0 
build tree node id 2, index 3, point 0.000000, entropy 0.000000, label 0 
too low entropy :  0
build tree node id 3, index 3, point 1.750000, entropy 0.309840, label 1 
build tree node id 4, index 2, point 4.950000, entropy 0.231894, label 1 
build tree node id 5, index 3, point 1.650000, entropy 0.000000, label 1 
too low entropy :  0.0
build tree node id 6, index 3, point 1.550000, entropy 0.459148, label 2 
build tree node id 7, index 3, point 0.000000, entropy 0.000000, label 2 
too low entropy :  0
build tree node id 8, index 2, point 5.450000, entropy 0.000000, label 1 
too low entropy :  0.0
build tree node id 5, index 1, point 3.150000, entropy 0.112984, label 2 
build tree node id 6, index 3, point 0.000000, entropy 0.000000, label 2 
too low entropy :  0
build tree node id 7, index 2, point 4.950000, entropy 0.000000, label 2 
too low entropy :  0.0





<__main__.TreeNode at 0x10a375b00>

上面 build_tree 函数就是根据 ID3 算法生成决策树的,防止代码太多,树的模型这里就不画出来了(跟后面用 sklearn 内置的决策树图差不多)。 这里再简单介绍一些 C4.5 和 CART 算法,这两个算法的核心思想跟 ID3 时一样的,他们之间的差异时节点选择时采用来不同的公式。

增益率(C4.5算法)

ID3 算法时根据信息增益来选择节点,C4.5 算法则时通过增益率来选择节点:

基尼指数(CART算法)

CART(Classification And Regression Tree)是一种很重要的机器学习算法,既可以用来创建分类树,也可以创建回归树。

该算法是基于基尼指数来选择节点,基尼指数(gini)公式如下:

下面我们通过 sklearn 自带的决策树函数训练样本并画出决策树的图,可以发现跟我们算出来的结果时差不多的。

from sklearn.model_selection import train_test_split
from sklearn import tree

# 把样本分成训练集和测试集两部分, 两者比例为: 7:3
X_train, X_test, y_train, y_test = train_test_split(features, target, test_size=0.3, random_state=42)

# 创建决策树分类器
clf = tree.DecisionTreeClassifier()

# 训练决策树
clf.fit(X=X_train, y=y_train)

# 查看特征比重
print("feature weight : ", clf.feature_importances_)

# 查看决策树评分
print("decision tree score : ", clf.score(X=X_test, y=y_test))

feature weight :  [0.         0.01911002 0.89326355 0.08762643]
decision tree score :  1.0
import graphviz

# 使用 graphviz 出决策树的图
dot_data = tree.export_graphviz(
    clf,
    out_file=None,
    feature_names=feature_names,
    class_names=class_names,
    filled=True,
    rounded=True,
    special_characters=True)

graph = graphviz.Source(dot_data)
graph

svg

参考