开篇

虽然数据挖掘算法很多,但有过实际经验的同学肯定都知道,集成方法(ensembel method)大多数情况下都是首选。集成方法和LR、SVM算法不同,她不是一个具体的模型或算法,而是一类算法的统称。集成,从其字面意思也能看出,这家伙不是单兵作战,而是依靠团队取胜。简而言之,集成方法就是通过构建并结合多个学习器来完成学习任务。这句话有两个地方需要注意,“如何构建每一个学习器”以及“如何结合多个学习器而产生预测结果”。

如果我们按照“如何构建每一个学习器”来将集成方法分类,总体可以分为两类:1) 个体学习器减存在强依赖关系、必须串行生成的序列化方法,代表方法是Boosting;2)个体学习器之间不存在强依赖关系、可同时生成的并行化方法,代表方法是Bagging。

本文要讲到的Gradient Boosting就属于Boosting方法。

由于Gradient Boosting涉及到的知识实在是很多,完全可以写成类似"Understanding Random Forest: From Theory to Practice"的博士论文,由于水平和时间的限制,本文不会涉及过多、过深的细节,而是尽量用通俗的自然语言和数学语言来解释我对Gradient Boosting的浅显理解,仅仅是入门水平:)

决策树

既然要讲Gradient Boosting,那么决策树肯定是绕不开的,虽然Boosting方法对"个体学习器"没有要求,你可以选择LR、SVM、决策树等等。但是大多数情况下我们默认都选择决策树作为个体学习器。下面简单解释决策树算法。

决策树,顾名思义,根据树结构来进行决策。这种思想是很质朴的,和我们人类做决策时的思考过程很像,比如明天出门要不要带伞啊?如果我根据天气情况来做出决定,可以记作:

if 明天下雨:
    出门带伞
else:
    出门不带伞

看到没有,决策过程可以用if, else来具体化,难怪有人说,决策树就是一堆if else而已。数据挖掘比赛中很多人会首先用简单的规则跑出一个结果,这实际上就是在人工构造决策树啊。

既然决策树是一种机器学习方法,我们就想知道她的学习过程是怎么样的,也就是给定训练集如果构建一颗决策树呢?通常,决策树的构建是一个递归过程:

  • 1 输入训练集D,特征集F
  • 2 如果D中样本全属于同一个类别C,将当前节点标记为C类叶节点,返回
  • 3 从F中选择最优划分特征f
  • 4 对于f的每一个值\(f_i\), 都生成一个子节点,然后对D进行划分,f值相同的样本都放到同一个子节点,并对节点进行标记,然后对于每一个新产生的子节点,进行第1步

注意:实际上对于决策树,大多数的实现方法都是基于二叉树,比如sklearn。

划分选择

决策树算法的关键是上面的第3步,即如何选择最优划分特征。如何评价一个特征是不是优秀呢?基本的原则就是看按照某一个特征生成子节点后,子节点的样本是不是都尽可能属于同一个类别,也就是子节点的纯度越高,我们就认为这个特征"好"。

为了量化子节点的纯度,前人提出了很多具体的评价方法,包括信息增益、信息增益率和基尼指数。这些评价方法大同小异,我们就以最常见的信息增益(Information Gain)为例进行解释:

假设当前节点包含的数据集为D,则数据集的熵(entropy)定义为:

其中\(|Y|\)表示类别值的取值个数。

假设某一个特征\(f\)有V个可能的取值 \(f^{1},f^{2},…,f^{V}\), 若使用特征\(f\)对\(D\)进行划分,会产生\(V\)个子节点,其中第\(v\)个子节点包含了\(D\)中所有\(f\)取值为\(f^{v}\), 记作\(D^{v}\). 按照下式计算用\(f\)对\(D\)进行划分获得的信息增益(information gain):

信息增益越大,我们就认为用\(f\)划分所获得的纯度提升越大。

下面是用信息增益来构建决策树的简单Python代码,注意,构建的是二叉树。

def uniqueCounts(X):
    """
    对y的取值进行计数, 用于计算节点的purity
    rvalue: 计数结果
    """
    results = {}
    for row in X:
        y = X[-1]
        results[y] = results.get(y, 0) + 1
    return results


def entropy(X):
    """计算熵"""
    from math import log
    log2 = lambda x: log(x)/log(2)
    results = uniqueCounts(X)

    entropy = 0.0
    for y in results:
        p = float(results[y])/len(X)
        entropy = entropy - p*log2(p)
    return entropy


# 定义节点
class DecisionTreeNode:
    def __init__(self, col=-1, value=None, results=None, tb=None, fb=None):
        self.col = col #
        self.value = value
        self.resuls = results
        self.tb = tb
        self.fb = fb


import numbers
def divideSet(X, column, value):
    """
    用某个特征对数据集进行分割, 分为两个数据集,二叉树
    """
    split_function = None
    if isinstance(value, (numbers.Integral, np.int)) or isinstance(value, (numbers.Real, np.float)):
        split_function = lambda row: row[column] >= value
    else:
        split_function = lambda row: row[column] == value

    # 将数据集分为两个集合,并返回
    set1 = [row for row in X if split_function(row)]
    set2 = [row for row in X if not split_function(row)]
    return (set1, set2)


def buildTree(X, scoref=entropy):
    """
    以递归方式构建树, 使用信息增益
    """
    if len(X)==0:
        return DecisionTreeNode()

    current_score = scoref(X)
    # 记录最佳拆分属性, 以及用哪个属性值
    best_gain = 0.0
    best_criteria = None # 拆分用的属性和属性取值
    best_sets = None # 拆分后的两个数据子集

    feature_nums = X.shape[1] - 1
    # 遍历每一个特征以及每个特征的每一个取值来构建树,复杂度很高, 可以用特征的中位数
    for col in range(feature_nums):
    # 在当前列中生成一个由不同值构成的序列
        column_values = {} #记录数据集中第col个特征,每个不同取值的个数
        for row in X:
            column_values[row[col]] = 1 # 初始化

        # 根据当前列的特征每个取值, 尝试对数据集进行分割
        for value in column_values:
            set1, set2 = divideSet(X, col, value)

            # 信息增益
            p = float(len(set1)) / len(X)
            gain = current_score - p*scoref(set1) - (1-p)*scoref(set2)
            if gain > best_gain and len(set1) >0 and len(set2) >0 :
                best_gain = gain
                best_criteria = (col, value)
                best_sets = (set1, set2)

    # 创建子分支
    if best_gain > 0:
        trueBranch = buildTree(best_sets[0])
        falseBranch = buildTree(best_sets[1])
        return DecisionTreeNode(col=best_criteria[0], value=best_criteria[1],
                                    tb = trueBranch, fb=falseBranch)
    else:
        return DecisionTreeNode(results=uniqueCounts(X))

Gradient Boosting

经常参加数据挖掘比赛(Kaggle、天池)的同学肯定对XGBoost、GBDT如雷贯耳,特别是XGBoost,简直堪称DM居家必备之神器。马克思老人家说过,透过现象看本质。XGBoost之所以这么吊,除了牛叉的代码实现,也和她的算法原理, Gradient Boosting, 有直接联系。下面我们就开启Gradient Boosting入门之旅吧。

Gradient Boosting, 又被称为Gradient Boosting Decision Tree(GBDT)、Gradient Boosting Machines(GBMs) 或者 Gradient Boosted Trees(GBT), 虽然别名很多,但只要看到gradientboost这两个词,就能确定指的就是本文的Gradient Boosting.

Gradient Boosting除了名字多以外,她的应用范围也很广,可直接应用于分类(Classification)、回归(Regression)和排序(Ranking)。

残差和梯度

和其他Boosting算法一样,Gradient Boosting可以记作:

这个式子很好理解,\(h_{t}(x)\)是个体学习器,Gradient Boosting就是把每一个个体学习器集成到一起。

前面我们提到Boosting通过串行的方式构建个体学习器,换句话说,个体学习器是按照顺序一个个构建的,比如迭代式或者递归的方式。而每一个学习器当然也不是随意创建的,比如AdaBoost每一个学习器都会重点关注上一个学习器分类错误的那些样本点,通过为每个样本赋予不同的权重实现。

我们先不讲Gradient Boosting背后的原理,单就看他的名字,我当初就很疑惑,gradient? 模型集成和梯度有半毛钱关系啊?更别提GBDT里面不但有gradient还有tree,梯度和决策树是怎么搞到一起的?

带着这些疑问,让我们一步步揭开Gradient Boosting的面纱。

首先,来思考一个回归问题:现有训练集D={\(x_i,y_i\)}和一个已经在D上训练好的模型\(F\), 不过\(F\)在D上的表现不太好,你能否按照如下规则来得到一个令人满意的模型:

  • 不能对\(F\)做改动
  • 你可以利用D单独训练决策树模型h,但是必须用F(x)+h(x)作为最终的模型

我们希望最后的模型F+h效果如下:

上面的等式等价为:

看到这里你会发现,对于决策树h,她的训练集实际上是{\(x_i\), \(y_i-F(x_i)\)}!而y-F(x)在数学界是有名号的,被称作残差(residual)。 我们再回过头来看h的物理意义,她的作用其实是修正F的错误,也就是抵消F的误差。

如果F+h在D上的表现还是令人不太满意。

我们不断构建决策树,最终会得到一个强大的模型F+\(\sum h_{t}\), 这实际上就是Gradient Boosting的学习过程。

看到这里你肯定会疑惑,这明明是 Residual Boosting!和梯度有毛线关系啊? 且看下面的推导:

假设我们要训练模型F(x), 损失函数选用平方差损失函数,

我们想要最小化:

由于F(\(x_{i}\))也是一个实数,我们不妨把F(\(x_{i}\))当做L的参数,如果用梯度下降算法来优化L,

仔细看梯度,竟然和残差有直接的联系:

现在,我们可以用梯度下降的思想来解释损失函数为平方差时,Gradient Boosting的学习过程:

  • 1 构建初始学习器F, 比如\(y_{i}\)的平均值
  • 2 计算平方差损失函数的梯度g
  • 3 用(x,-g) 训练决策树h
  • 4 F = F + 1*h
  • 5 如果决策树个数达到阈值,结束;否则,跳转到第2步

Gradient Boosting可不是仅能用平方差损失函数哦,以sklearn为例,用于回归的GradientBoostingRegressor支持least square loss、least absolute loss、hubor loss和quantile loss;用于分类的GradientBoostingClassificier支持logistic regression和exponential loss。

我们可以把梯度看做残差的泛化形式,则任何函数都可以作为Gradient Boosting的损失函数!只要你能提供她的梯度!这也是XGBoost支持自定义损失函数的原理基础。

所以,Gradient Boosting的学习过程:

  • 1 构建初始学习器F, 比如\(y_{i}\)的平均值
  • 2 计算损失函数的梯度g
  • 3 用(x,-g) 训练决策树h
  • 4 F = F + \(\rho\) h, \(\rho\)为学习率
  • 5 如果决策树个数达到阈值,结束;否则,跳转到第2步

过拟合与正则化

随着Gradient Boosting中决策树个数的增加,通常你会发现在训练集D上的效果越来越好,如果是二分类问题,甚至可能准确率接近100%,此时就要注意了,很可能产生了过拟合(overfitting)现象。任何一个能实际应用的模型,都必然会考虑到过拟合现象,我们来看一下Gradient Boosting如何减弱过拟合。

子采样(subsampling)

一个模型如果在训练集上表现很好,在测试集上表现远不如训练集,我们就认为这个模型陷入了过拟合。她把训练集中存在的噪声错误地归类为数据的普遍性规律,怎么样降低噪声对模型的干扰呢?如果我们把噪声看做数据集中的异常点,在训练模型的时候,我们引入随机性因素,比如从训练集中随机抽取样本来训练模型,无疑会减弱异常点的干扰。这就是子采样的思想。

在每一次构建新的决策树之前,先从训练集中随机抽取部分样本构建一个新的训练集\(D_t\),然后利用\(D_t\)训练决策树。

sklearn的GradientBoostingRegressor和GradientBoostingClassifier提供了subsample参数进行子采样。

收缩(shrinkage)

Shrinkage也是常用的减弱过拟合的手段之一,在Gradient Boosting中,shrinkage用于降低每一个个体学习器的影响,在sklearn中,shrinkage就是我们通常见到的学习率, 即,GradientBoostingRegressor和GradientBoostingClassifier中的learning_rate参数。

这里多说一句,原始的Gradient Boosting算法在每一次学习到个体学习器后,还要学习一个参数叫做步长(step length)的参数,步长和shrinkage是两个参数,不要搞混,但是在sklearn的实现中,并没有考虑步长,直接用学习率代替了shrinkage.

用来减弱过拟合的方法有很多,比如还有early stopping, GradientBoostingRegressor和GradientBoostingClassifier还提供了\(monitor\)参数, 可以用于early stopping。

XGBoost

Gradient Boosting的开源实现有很多,但目前最流行最好用的肯定是XGBoost。 XGBoost之所以这么好用,一部分原因是陈天奇等人强大的编码能力和优秀的系统设计,另一方面也是因为她对传统Gradient Boosting在算法层面做出了诸多改进。本文不涉及并行化和系统设计的内容, 我仅谈一下XGBoost在算法层面的改进之处。

带有正则项的目标函数

大部分机器学习算法的学习过程可以转换成一个优化问题,目标函数=损失函数+正则项。而传统的Gradient Boosting是不包含正则项的,所以,XGBoost做出的第一项改进就是修改目标函数,增加如下的正则项:

这个正则项量化了每一颗CART树的复杂度, 其中T是CART树\(f_t\)的叶子节点数目,\(w\)是叶子节点的权重。 看一个简单的例子:

目标函数的近似

XGBoost在对目标函数进行优化求解时,采用了近似手段,

然后对损失函数 泰勒展开:

进一步移除目标函数中的常量,

上面的\(f_{t}(x)\)表示一颗决策树,我们能否只用叶子节点表示一棵树呢?答案是可以,但需要知道两方面的知识,一是叶子节点的权重\(w\), 二是每个叶子节点的样本点(即,树的结构),用p表示。

进一步我们按照样本点在CART树种的分布情况,可以将训练集样本分为T组,得到:

上式其实是T个二次函数的和,我们知道对于单变量二次函数\(ax^2+bx\),当x取值为\(-b/(2a)\)时,函数有极值\(-b^2/(4a)\),

我们可以得到每个叶子节点对应的\(w\)的最佳取值和函数极值:

看一个具体的例子:

近似贪心算法

决策树分割节点时,一般使用贪心算法,并且需要遍历所有特征以及特征的每一个取值,这种做法称为精确贪心算法(exact greedy algorithm), XGBoost和sklearn一样支持这种精确贪心算法,除此之外,XGBoost还包含了陈天奇等人提出的决策树分割节点的近似算法和 针对稀疏数据的分割节点快速算法。对于近似算法,我说一下自己的错误理解:

不论精确还是近似算法,都需要遍历一遍特征,近似算法的有点是不需要遍历特征的每个取值,她只遍历特征取值范围的百分位数上那些取值,那么如何取得这些百分位数值呢?可以用quantile sketch算法,事情到这里是不是就结束了呢?No,陈提到quantile sketch算法没有考虑到样本权重问题,如果数据集中的数据点是有权重的,这个算法就不适用了。于是陈等人提出了一种可以使用于带权重数据的quantile sketch算法。

其他优点

前面说过,理论上Gradient Boosting的损失函数可以是任意可微函数。只要能够提供损失函数的梯度和Hessian矩阵,用户就可以自定义训练模型时的损失函数。 XGBoost也允许用户在交叉验证时自定义误差衡量标准。 也可以选择使用线性模型替代树模型,从而得到带L1+L2惩罚的线性回归或者logistic回归。