基于树模型的集成算法 ---XGBoost

一、模型介绍

XGBoost 是 boosting 算法的其中一种。Boosting 算法的思想是将许多弱分类器集成在一起形成一个强分类器。因为 XGBoost 是一种提升树模型,所以它是将许多树模型集成在一起,形成一个很强的分类器。而所用到的树模型则是 CART 回归树模型。

二、模型原理

1. 算法思想

该算法思想就是不断地添加树,不断地进行特征分裂来生长一棵树,每次添加一个树,其实是学习一个新函数,去拟合上次预测的残差。当我们训练完成得到 k 棵树,我们要预测一个样本的分数,其实就是根据这个样本的特征,在每棵树中会落到对应的一个叶子节点,每个叶子节点就对应一个分数,最后只需要将每棵树对应的分数加起来就是该样本的预测值。

则对于每个预测值可以表示为:
\hat{y}_{i} = \sum_{k=1}^{K}f_{k}(x_{i}),\ f_{k}\ \epsilon\ F
其中 F 对应了所有回归树的集合。

2. 目标函数

假设损失函数为 L,正则项为 \Omega,则有目标函数形如: Obj = L + \Omega
Obj = \sum_{i=1}^{n}l(y_{i},\hat{y}_{i}) + \sum_{k=1}^{K}\Omega(f_{k})
其中第一部分是训练误差,也就是大家相对比较熟悉的如平方误差, logistic loss 等。而第二部分是每棵树的复杂度的和。

因为现在我们的参数可以认为是在一个函数空间里面,我们不能采用传统的如 SGD 之类的算法来学习我们的模型,因此我们会采用一种叫做 additive training的方式。每一次保留原来的模型不变,加入一个新的函数 f 到我们的模型中。如下图所示:

image.png

3. 损失函数

接下来我们需要思考的是,我们如何选择每一轮的加入什么 f ? 很显然,我们希望加入的 f 使我们的目标函数尽可能的降低。对于第 t 步:
Obj^{(t)} = \sum_{i=1}^{n}l(y_{i}, \hat{y_{i}}^{t}) + \sum_{i=1}^{t}\Omega{(f_{i})} Obj^{(t)} = \sum_{i=1}^{n}l(y_{i}, \hat{y_{i}}^{t-1} + f_{t}(x_{i})) + \Omega{(f_{t})}+\sum_{i=1}^{t-1}\Omega{(f_{i})}
对于第 t 步来说,其中 \sum_{i=1}^{t-1}\Omega{(f_{i})} = constant。我们需要找到 f_{t} 优化上式的前部分:\sum_{i=1}^{n}l(y_{i}, \hat{y_{i}}^{t-1} + f_{t}(x_{i})) + \Omega{(f_{t})}
我们考虑损失函数为平方误差,则上式有:
Obj^{(t)} = \sum_{i=1}^{n}[y_{i}-(\hat{y_{i}}^{t-1}+f_{t}(x_{i}))]^{2} + \Omega{(f_{t})}+constant Obj^{(t)} = \sum_{i=1}^{n}[(y_{i})^2+(\hat{y_{i}}^{t-1})^2+(f_{t}(x_{i}))^2- 2*y_{i}*\hat{y_{i}}^{t-1} - 2*y_{i}*f_{t}(x_{i}) - 2*\hat{y_{i}}^{t-1}*f_{t}(x_{i})] + \Omega{(f_{t})}+constant
针对变量 f_{t} 而言,y_{i}\hat{y_{i}}^{t-1} 均为常量,则最后有:
Obj^{(t)} = \sum_{i=1}^{n}[2(\hat{y_{i}}^{t-1}-y_{i})f_{t}(x_{i})+(f_{t}(x_{i}))^2 ] + \Omega{(f_{t})}+constant
其中 (\hat{y_{i}}^{t-1}-y_{i}) 一般叫做残差。
当然,更加一般的话,例如损失函数不是平方误差的时候。再回过头来看我们的损失函数:
L(\hat{y_{i}}^{t-1}) = l(y_{i}, \hat{y_{i}}^{t-1}) L(\hat{y_{i}}^{t-1}+f_{t}(x_{i})) = l(y_{i}, \hat{y_{i}}^{t-1}+f_{t}(x_{i}))
形如 f(x+\Delta{x}),我们可以采用泰勒展开近似来定义一个近似的目标函数,方便我们这一步的计算,同时更加的一般化。
则有:
f(x+\Delta{x}) \simeq f(x) + f'(x)\Delta{x} + \frac{1}{2}f''(x){\Delta{x}}^{2}
为了方便理解,这里定义:g_{i} 为一阶导数,h_{i} 为二阶导数。
g_{i} = \partial_{\hat{y_i}^{t-1}} l(y_{i},\hat{y_i}^{t-1}) h_{i} = \partial_{\hat{y_i}^{t-1}}^{2} l(y_{i},\hat{y_i}^{t-1})
目标函数可以写作:
Obj^{(t)} \simeq \sum_{i=1}^{n}[l(y_{i}, \hat{y_{i}}^{t-1}) + g_{i}f_{t}(x_{i})+\frac{1}{2}h_{i}{f_{t}}^{2}(x_{i}) ] + \Omega{(f_{t})}+constant
至此,我们得到一个非常漂亮的式子。当我们把常数项移除之后,这一个目标函数有一个非常明显的特点,它只依赖于每个数据点的在误差函数上的一阶导数和二阶导数。
Obj^{(t)} \simeq\sum_{i=1}^{n}[g_{i}f_{t}(x_{i})+\frac{1}{2}h_{i}{f_{t}}^{2}(x_{i}) ] + \Omega{(f_{t})} + constant
这样我们可以很清楚地理解整个目标是什么,并且一步一步推导出如何进行树的学习。这一个抽象的形式对于实现机器学习工具也是非常有帮助的。传统的 GBDT 可能大家可以理解如优化残差,但是这样一个形式包含可所有可以求导的目标函数。也就是说有了这个形式,我们写出来的代码可以用来求解包括回归,分类和排序的各种问题,正式的推导可以使得机器学习的工具更加一般。

4. 树结构以及复杂度

目前为止我们讨论了目标函数中训练误差的部分。接下来我们讨论如何定义树的复杂度。
这里我们把树拆分成结构部分 q 和叶子权重部分 w,结构函数 q 把输入映射到叶子的索引号上面去, 而w 给定了每个索引号对应的叶子分数是什么。
形如 f_{t}(x) = w_{q(x)},如下图:

image.png

当给定了如上定义之后,我们可以定义一棵树的复杂度如下。
这个复杂度包含了一棵树里面节点的个数,以及每个树叶子节点上面输出分数的 L_{2} 模平方。当然这不是唯一的一种定义方式,不过这一定义方式学习出的树效果一般都比较不错。下图给出了复杂度计算的一个例子:(其中 \gamma\lambda 为正则化系数)
image.png

5. 目标函数优化求解

接下来,再增加一个定义,假设一共有 T 个叶子,用 I_{j} 来表示落在第j个叶子节点上的样本集合。用公式表示为 I_{j} = \{i|q(x_{i})=j\}。结合这一点,舍弃掉常数项,我们再把上面的目标函数改写:
Obj^{(t)} \simeq\sum_{i=1}^{n}[g_{i}f_{t}(x_{i})+\frac{1}{2}h_{i}{f_{t}}^{2}(x_{i}) ] + \Omega{(f_{t})} =\sum_{i=1}^{n}[g_{i}w_{q(x_{i})}+\frac{1}{2}h_{i}{w}^{2}_{q(x_{i})}] + \gamma T+\frac{1}{2}\lambda\sum_{j=1}^{T}{w}^{2}_{j} =\sum_{j=1}^{T}[(\sum_{i\in I_{j}}g_{i})w_{j}+\frac{1}{2}(\sum_{i\in I_{j}}h_{i}){w}^{2}_{j}] + \gamma T+\frac{1}{2}\lambda\sum_{j=1}^{T}{w}^{2}_{j} =\sum_{j=1}^{T}[(\sum_{i\in I_{j}}g_{i})w_{j}+\frac{1}{2}(\sum_{i\in I_{j}}h_{i} + \lambda){w}^{2}_{j}] + \gamma T
通过上式我们可以看到这是一个关于 w 的二次函数,为了方便表示,令 G_{j} = \sum_{i\in I_{j}}g_{i}H_{j} = \sum_{i\in I_{j}}h_{i}。则上式可以写作:
Obj^{(t)} \simeq \sum_{j=1}^{T}[G_{j}w_{j} + \frac{1}{2}(H_{j}+\lambda){w}^{2}_{j}] + \gamma T
根据一元二次函数的最优化问题,形如 ax^{2}+bx的极值点为 x=-\frac{b}{2a}。回到上式则有:
w_{j}^{*} = -\frac{G_{j}}{H_{j}+\lambda} Obj = -\frac{1}{2}\sum_{j=1}^{T}(\frac{G^{2}_{j}}{H_{j}+\lambda}) + \gamma T
其中 w_{j}^{*}表示的式最好的 wObj 代表了当我们指定一个树的结构的时候,我们在目标上面最多减少多少。我们把它叫做结构分数 (structure score) 。你可以认为这个就是类似吉尼系数一样更加一般的对于树结构进行打分的函数。

所以我们的算法也很简单,我们不断地枚举不同树的结构,利用这个打分函数来寻找出一个最优结构的树,加入到我们的模型中,再重复这样的操作。
很显然,枚举所有树结构这个操作不太可行,所以常用的方法是贪心算法,每一次尝试去对已有的叶子加入一个分割。对于一个具体的分割方案,假设当前节点左右子树的一阶导数和二阶导数的和为 G_{L} G_{R} H_{L} H_{R};

image.png

我们可以获得的增益可以由如下公式计算:
Obj_{nosplit} = -\frac{1}{2}\frac{(G_{L} + G_{R})^{2}}{H_{L}+H_{R}+\lambda} + \gamma T Obj_{split} = -\frac{1}{2}[\frac{{G}^{2}_{L}}{H_{L}+\lambda} + \frac{{G}^{2}_{R}}{H_{R}+\lambda}] + \gamma (T+1) Gain = Ojb_{nosplit} - Obj_{split} Gain = -\frac{1}{2}\frac{(G_{L} + G_{R})^{2}}{H_{L}+H_{R}+\lambda} + \gamma T +\frac{1}{2}\frac{{G}^{2}_{L}}{H_{L}+\lambda} + \frac{1}{2} \frac{{G}^{2}_{R}}{H_{R}+\lambda} - \gamma (T+1) Gain = \frac{1}{2}[\frac{{G}^{2}_{L}}{H_{L}+\lambda} + \frac{{G}^{2}_{R}}{H_{R}+\lambda} - \frac{(G_{L} + G_{R})^{2}}{H_{L}+H_{R}+\lambda} ] - \gamma
这里我们的决策树分裂标准不再使用 CART 回归树的均方误差,最大化增益 Gain 就是我们的最终想要的结果。
关于对叶子节点的分割,其实这里有两种后续的分割思路:

  • 第一种,最好的分割情况下 Gain为负时就停止树的生长,这样的话效率会比较高也简单,但是这样也就放弃了未来更好的可能性。
  • 第二种,分割到指定的最大深度,递归的把划分叶子节点得到 Gain 为负的收回。
    一般情况,第二种都要更好一些,当然我们这里使用的也是第二种。

很有趣的一点,回顾以上过程。当我们正式地推导目标的时候,像计算分数和剪枝这样的策略都会自然地出现。而且有这样一般的推导,得到的算法可以直接应用到回归,分类排序等各个应用场景中去。

最后我们总结下 XGBoost 的算法主流程,基于决策树弱分类器。(不涉及运行效率的优化和健壮性优化的内容)
  • 输入是训练集样本I=\{(x_{1},y_{1}),(x_{2},y_{2})……(x_{m},y_{m})\},特征数目为K,最大迭代次数 T, 损失函数 L, 正则化系数\lambda,\gamma
  • 输出是强学习器 f(x)
  • 对于第t (t=1,2,3……T)轮迭代:
    • (1) 计算第i 个样本在当前损失函数L 基于f_{t-1}(x)的一阶导数g^{t}_{i},二阶导数h^{t}_{i}。所有样本的一阶导数和G_{t} = \sum_{i=1}^{m}g^{t}_{i},二阶导数和H_{t} = \sum_{i=1}^{m}h^{t}_{i}
    • (2) 基于当前节点尝试分裂决策树,默认分数 score=0GH 为当前需要分裂节点的一阶二阶导数之和。样本特征k(k = 1,2,3……K):
      • (a) G_{L} = 0,H_{L} = 0
      • (b) 将样本按特征 k 从小到大排列,依次取出第 i 个样本,计算当前i 个样本放入左子树后,左右子树一阶和二阶导数和:
        G_{L} = G_{L} + g^{t}_{i},G_{R} = G - G_{L} H_{L} = H_{L} + h^{t}_{i},H_{R} = H - H_{L}
      • (c) 尝试更新最大分数:
        score = max(score, \frac{1}{2}[\frac{{G}^{2}_{L}}{H_{L}+\lambda} + \frac{{G}^{2}_{R}}{H_{R}+\lambda} - \frac{(G_{L} + G_{R})^{2}}{H_{L}+H_{R}+\lambda} ] - \gamma)
    • (3) 基于最大 score 对应的划分特征和特征值分裂子树。
    • (4) 如果最大 score 为 0,则当前决策树建立完毕,计算所有叶子区域的^{t}_{}, 得到弱学习器 ℎ^{t}(),更新强学习器_{t}(),进入下一轮弱学习器迭代。如果最大 score 不是 0,则转到第 (2) 步继续尝试分裂决策树。

三、模型细节

XGBoost 算法运行效率的优化

我们知道 Boosting 算法的弱学习器是没法并行迭代的,但是单个弱学习器里面最耗时的是决策树的分裂过程,XGBoost 针对这个分裂做了比较大的并行优化。对于不同的特征的特征划分点,XGBoost 分别在不同的线程中并行选择分裂的最大增益。

同时,对训练的每个特征排序并且以块的的结构存储在内存中,方便后面迭代重复使用,减少计算量。计算量的减少参见上面的算法流程,首先默认所有的样本都在右子树,然后从小到大迭代,依次放入左子树,并寻找最优的分裂点。这样做可以减少很多不必要的比较。

此外,通过设置合理的分块的大小,充分利用了 CPU 缓存进行读取加速(cache-aware access)。使得数据读取的速度更快。另外,通过将分块进行压缩(block compressoin)并存储到硬盘上,并且通过将分块分区到多个硬盘上实现了更大的 IO。

XGBoost算法健壮性的优化

我们再来看看 XGBoost 在算法健壮性的优化,除了上面讲到的正则化项提高算法的泛化能力外,XGBoost 还对特征的缺失值做了处理。

XGBoost 没有假设缺失值一定进入左子树还是右子树,则是尝试通过枚举所有缺失值在当前节点是进入左子树,还是进入右子树更优来决定一个处理缺失值默认的方向,这样处理起来更加的灵活和合理。

也就是说,上面算法的步骤 (a),(b),(c) 会执行 2 次,第一次假设特征 k 所有有缺失值的样本都走左子树,第二次假设特征 k 所有缺失值的样本都走右子树。然后每次都是针对没有缺失值的特征 k 的样本走上述流程,而不是所有的的样本。

XGBoost 与 GBDT 算法比较的优势

作为 GBDT 的高效实现,XGBoost 是一个上限特别高的算法,因此在算法竞赛中比较受欢迎。简单来说,对比原算法 GBDT,XGBoos t主要从下面三个方面做了优化:

  • 一是算法本身的优化:在算法的弱学习器模型选择上,对比 GBDT 只支持决策树,还可以直接很多其他的弱学习器。在算法的损失函数上,除了本身的损失,还加上了正则化部分。在算法的优化方式上,GBDT 的损失函数只对误差部分做负梯度(一阶泰勒)展开,而 XGBoost 损失函数对误差部分做二阶泰勒展开,更加准确。
  • 二是算法运行效率的优化:对每个弱学习器,比如决策树建立的过程做并行选择,找到合适的子树分裂特征和特征值。在并行选择之前,先对所有的特征的值进行排序分组,方便前面说的并行选择。对分组的特征,选择合适的分组大小,使用 CPU 缓存进行读取加速。将各个分组保存到多个硬盘以提高 IO 速度。
  • 三是算法健壮性的优化:对于缺失值的特征,通过枚举所有缺失值在当前节点是进入左子树还是右子树来决定缺失值的处理方式。算法本身加入了 L1 和 L2 正则化项,可以防止过拟合,泛化能力更强。

四、模型优缺点

优点:

    1. 精度更高:GBDT 只用到一阶泰勒展开,而 XGBoost 对损失函数进行了二阶泰勒展开。XGBoost 引入二阶导一方面是为了增加精度,另一方面也是为了能够自定义损失函数,二阶泰勒展开可以近似大量损失函数;
    1. 灵活性更强:GBDT 以 CART 作为基分类器,XGBoost 不仅支持 CART 还支持线性分类器,使用线性分类器的 XGBoost 相当于带 和 正则化项的逻辑斯蒂回归(分类问题)或者线性回归(回归问题)。此外,XGBoost 工具支持自定义损失函数,只需函数支持一阶和二阶求导;
    1. 正则化:XGBoost 在目标函数中加入了正则项,用于控制模型的复杂度。正则项里包含了树的叶子节点个数、叶子节点权重的 范式。正则项降低了模型的方差,使学习出来的模型更加简单,有助于防止过拟合,这也是 XGBoost 优于传统 GBDT 的一个特性。
    1. Shrinkage(缩减):相当于学习速率。XGBoost 在进行完一次迭代后,会将叶子节点的权重乘上该系数,主要是为了削弱每棵树的影响,让后面有更大的学习空间。传统 GBDT 的实现也有学习速率;
    1. 缺失值处理:对于特征的值有缺失的样本,XGBoost 采用的稀疏感知算法可以自动学习出它的分裂方向;
    1. 列抽样:XGBoost 借鉴了随机森林的做法,支持列抽样,不仅能降低过拟合,还能减少计算。这也是 XGBoost 异于传统 GBDT 的一个特性;
    1. 支持并行:boosting不是一种串行的结构吗? 怎么并行的?注意 XGBoost 的并行不是 tree 粒度的并行,XGBoost 也是一次迭代完才能进行下一次迭代的(第次迭代的代价函数里包含了前面次迭代的预测值)。XGBoost 的并行是在特征粒度上的。我们知道,决策树的学习最耗时的一个步骤就是对特征的值进行排序(因为要确定最佳分割点),XGBoost 在训练之前,预先对数据进行了排序,然后保存为 block 结构,后面的迭代中重复地使用这个结构,大大减小计算量。这个 block 结构也使得并行成为了可能,在进行节点的分裂时,需要计算每个特征的增益,最终选增益最大的那个特征去做分裂,那幺各个特征的增益计算就可以开多线程进行。
    1. 可并行的近似直方图算法。树节点在进行分裂时,我们需要计算每个特征的每个分割点对应的增益,即用贪心法枚举所有可能的分割点。当数据无法一次载入内存或者在分布式情况下,贪心算法效率就会变得很低,所以 XGBoost 还提出了一种可并行的近似直方图算法,用于高效地生成候选的分割点。在近似算法中,我们通过区间划分的方式将样本集划分为设定多个 buckets 所构成的样本子集,(即特征的离散化)只在 buckets 的边界点中筛选最好的分裂结点,这大大简化了计算。

缺点

    1. 虽然利用预排序和近似算法可以降低寻找最佳分裂点的计算量,但在节点分裂过程中仍需要遍历数据集;
    1. 预排序过程的空间复杂度过高,不仅需要存储特征值,还需要存储特征对应样本的梯度统计值的索引,相当于消耗了两倍的内存。

五、模型使用

from xgboost.sklearn import XGBClassifier
xgb = XGBClassifier(max_depth=3, learning_rate=0.1, n_estimators=100,
                    verbosity=1, silent=None,
                    objective="binary:logistic", booster='gbtree',
                    n_jobs=1, nthread=None, gamma=0, min_child_weight=1, max_delta_step=0,
                    subsample=1, colsample_bytree=1, colsample_bylevel=1,
                    colsample_bynode=1, reg_alpha=0, reg_lambda=1, scale_pos_weight=1,
                    base_score=0.5, random_state=0)
xgb.fit(x,y)
xgb.predict(test_x)
xgb.predict_proba(test_x)

你可能感兴趣的:(基于树模型的集成算法 ---XGBoost)