【机器学习】决策树(下)——XGBoost、LightGBM(非常详细)

本文是决策树的第三篇,主要介绍基于 Boosting 框架的主流集成算法,包括 XGBoost 和 LightGBM。

不知道为什么知乎文章封面的照片会显示不全,在这里补上完整的思维导图:

1. XGBoost

XGBoost 是大规模并行 boosting tree 的工具,它是目前最快最好的开源 boosting tree 工具包,比常见的工具包快 10 倍以上。Xgboost 和 GBDT 两者都是 boosting 方法,除了工程实现、解决问题上的一些差异外,最大的不同就是目标函数的定义。故本文将从数学原理和工程实现上进行介绍,并在最后介绍下 Xgboost 的优点。

1.1 数学原理

1.1.1 目标函数

我们知道 XGBoost 是由 $k$ 个基模型组成的一个加法运算式:

$$\hat{y}i=\sum{t=1}^{k}\ f_t(x_i) \$$

其中 $f_k$ 为第 $k$ 个基模型, $\hat{y}_i$ 为第 $i$ 个样本的预测值。

损失函数可由预测值 $\hat{y}_i$ 与真实值 $y_i$ 进行表示:

$$L=\sum_{i=1}^n l( y_i, \hat{y}_i) \$$

其中 $n$ 为样本数量。

我们知道模型的预测精度由模型的偏差和方差共同决定,损失函数代表了模型的偏差,想要方差小则需要简单的模型,所以目标函数由模型的损失函数 $L$ 与抑制模型复杂度的正则项 $\Omega$ 组成,所以我们有:

$Obj =\sum_{i=1}^n l(\hat{y}i, y_i) + \sum{t=1}^k \Omega(f_t) \ $

$\Omega$ 为模型的正则项,由于 XGBoost 支持决策树也支持线性模型,所以这里再不展开描述。

我们知道 boosting 模型是前向加法,以第 $t$ 步的模型为例,模型对第 $i$ 个样本 $x_{i}$ 的预测为:

$$ \hat{y}_i^t= \hat{y}_i^{t-1} + f_t(x_i) \$$

其中 $\hat{y}_i^{t-1}$ 由第 $t-1$ 步的模型给出的预测值,是已知常数,$f_t(x_i)$ 是我们这次需要加入的新模型的预测值,此时,目标函数就可以写成:

$$\begin{align} Obj^{(t)} &= \sum_{i=1}^nl(y_i, \hat{y}i^t) + \sum{i=1}^t\Omega(f_i) \ &= \sum_{i=1}^n l\left(y_i, \hat{y}i^{t-1} + f_t(x_i) \right) + \sum{i=1}^t \Omega(f_i) \end{align} \$$

求此时最优化目标函数,就相当于求解 $f_t(x_i)$ 。

泰勒公式是将一个在 $x=x_0$ 处具有 $n$ 阶导数的函数 $f(x)$ 利用关于 $x-x_0$ 的 $n$ 次多项式来逼近函数的方法,若函数 $f(x)$ 在包含 $x_0$ 的某个闭区间 $[a,b]$ 上具有 $n$ 阶导数,且在开区间 $(a,b)$ 上具有 $n+1$ 阶导数,则对闭区间 $[a,b]$ 上任意一点 $x$ 有 $\displaystyle f(x)=\sum_{i=0}^{n}\frac{f^{(i)}(x_0)}{i!}(x-x_0)^ i+R_n(x) $ ,其中的多项式称为函数在 $x_0$ 处的泰勒展开式, $R_n(x)$ 是泰勒公式的余项且是 $(x−x_0)^n$ 的高阶无穷小。

根据泰勒公式我们把函数 $f(x+\Delta x)$ 在点 $x$ 处进行泰勒的二阶展开,可得到如下等式:

$$f(x+\Delta x) \approx f(x) + f’(x)\Delta x + \frac12 f’’(x)\Delta x^2 \$$

我们把 $\hat{y}_i^{t-1}$ 视为 $x$ , $f_t(x_i)$ 视为 $\Delta x$ ,故可以将目标函数写为:

$$Obj^{(t)} = \sum_{i=1}^n \left[ l(y_i, \hat{y}i^{t-1}) + g_if_t(x_i) + \frac12h_if_t^2(x_i) \right] + \sum{i=1}^t \Omega(f_i) \$$

其中 $g_{i}$ 为损失函数的一阶导, $h_{i}$ 为损失函数的二阶导,注意这里的导是对 $\hat{y}_i^{t-1}$ 求导

我们以平方损失函数为例:

$$\sum_{i=1}^n \left(y_i - (\hat{y}_i^{t-1} + f_t(x_i)) \right)^2 \$$

则:

$$ \begin{align} g_i &= \frac{\partial (\hat{y}^{t-1} - y_i)^2}{\partial {\hat{y}^{t-1}}} = 2(\hat{y}^{t-1} - y_i) \ h_i &=\frac{\partial^2(\hat{y}^{t-1} - y_i)^2}{{\hat{y}^{t-1}}} = 2 \end{align} \$$

由于在第 $t$ 步时 $\hat{y}_i^{t-1}$ 其实是一个已知的值,所以 $l(y_i, \hat{y}_i^{t-1})$ 是一个常数,其对函数的优化不会产生影响,因此目标函数可以写成:

$$ Obj^{(t)} \approx \sum_{i=1}^n \left[ g_if_t(x_i) + \frac12h_if_t^2(x_i) \right] + \sum_{i=1}^t \Omega(f_i) \$$

所以我们只需要求出每一步损失函数的一阶导和二阶导的值(由于前一步的 $\hat{y}^{t-1}$ 是已知的,所以这两个值就是常数),然后最优化目标函数,就可以得到每一步的 $f(x)$ ,最后根据加法模型得到一个整体模型。

1.1.2 基于决策树的目标函数

我们知道 Xgboost 的基模型不仅支持决策树,还支持线性模型,这里我们主要介绍基于决策树的目标函数。

我们可以将决策树定义为 $f_t(x)=w_{q(x)}$ , $x$ 为某一样本,这里的 $q(x)$ 代表了该样本在哪个叶子结点上,而 $w_q$ 则代表了叶子结点取值 $w$ ,所以 $w_{q(x)}$ 就代表了每个样本的取值 $w$ (即预测值)。

决策树的复杂度可由叶子数 $T$ 组成,叶子节点越少模型越简单,此外叶子节点也不应该含有过高的权重 $w$ (类比 LR 的每个变量的权重),所以目标函数的正则项可以定义为:

$$\Omega(f_t)=\gamma T + \frac12 \lambda \sum_{j=1}^T w_j^2 \$$

即决策树模型的复杂度由生成的所有决策树的叶子节点数量,和所有节点权重所组成的向量的 $L_2$ 范式共同决定。

这张图给出了基于决策树的 XGBoost 的正则项的求解方式。

我们设 $I_j= { i \vert q(x_i)=j }$ 为第 $j$ 个叶子节点的样本集合,故我们的目标函数可以写成:

$$\begin{align} Obj^{(t)} &\approx \sum_{i=1}^n \left[ g_if_t(x_i) + \frac12h_if_t^2(x_i) \right] + \Omega(f_t) \ &= \sum_{i=1}^n \left[ g_iw_{q(x_i)} + \frac12h_iw_{q(x_i)}^2 \right] + \gamma T + \frac12 \lambda \sum_{j=1}^Tw_j^2 \ &= \sum_{j=1}^T \left[(\sum_{i \in I_j}g_i)w_j + \frac12(\sum_{i \in I_j}h_i + \lambda)w_j^2 \right] + \gamma T \end{align} \$$

第二步到第三步可能看的不是特别明白,这边做些解释:第二步是遍历所有的样本后求每个样本的损失函数,但样本最终会落在叶子节点上,所以我们也可以遍历叶子节点,然后获取叶子节点上的样本集合,最后在求损失函数。即我们之前样本的集合,现在都改写成叶子结点的集合,由于一个叶子结点有多个样本存在,因此才有了 $\sum_{i \in I_j}g_i$ 和 $\sum_{i \in I_j}h_i$ 这两项, $w_j$ 为第 $j$ 个叶子节点取值。

为简化表达式,我们定义 $G_j=\sum_{i \in I_j}g_i$ , $H_j=\sum_{i \in I_j}h_i$ ,则目标函数为:

$$Obj^{(t)} = \sum_{j=1}^T \left[G_jw_j + \frac12(H_j + \lambda)w_j^2 \right] + \gamma T \$$

这里我们要注意 $G_j$ 和 $H_j$ 是前 $t-1$ 步得到的结果,其值已知可视为常数,只有最后一棵树的叶子节点 $w_j$ 不确定,那么将目标函数对 $w_j$ 求一阶导,并令其等于 $0$ ,则可以求得叶子结点 $j$ 对应的权值:

$$w_j^*=-\frac{G_j}{H_j+\lambda} \$$

所以目标函数可以化简为:

$$Obj = -\frac12 \sum_{j=1}^T \frac{G_j^2}{H_j+\lambda} + \gamma T \$$

上图给出目标函数计算的例子,求每个节点每个样本的一阶导数 $g_i$ 和二阶导数 $h_i$ ,然后针对每个节点对所含样本求和得到的 $G_j$ 和 $H_j$ ,最后遍历决策树的节点即可得到目标函数。

1.1.3 最优切分点划分算法

在决策树的生长过程中,一个非常关键的问题是如何找到叶子的节点的最优切分点,Xgboost 支持两种分裂节点的方法——贪心算法和近似算法。

1)贪心算法

  1. 从深度为 $0$ 的树开始,对每个叶节点枚举所有的可用特征;
  2. 针对每个特征,把属于该节点的训练样本根据该特征值进行升序排列,通过线性扫描的方式来决定该特征的最佳分裂点,并记录该特征的分裂收益;
  3. 选择收益最大的特征作为分裂特征,用该特征的最佳分裂点作为分裂位置,在该节点上分裂出左右两个新的叶节点,并为每个新节点关联对应的样本集
  4. 回到第 1 步,递归执行到满足特定条件为止

那么如何计算每个特征的分裂收益呢?

假设我们在某一节点完成特征分裂,则分列前的目标函数可以写为:

$$Obj_{1} =-\frac12 [\frac{(G_L+G_R)^2}{H_L+H_R+\lambda}] + \gamma \$$

分裂后的目标函数为:

$$Obj_2 = -\frac12 [ \frac{G_L^2}{H_L+\lambda} + \frac{G_R^2}{H_R+\lambda}] +2\gamma \$$

则对于目标函数来说,分裂后的收益为:

$$Gain=\frac12 \left[ \frac{G_L^2}{H_L+\lambda} + \frac{G_R^2}{H_R+\lambda} - \frac{(G_L+G_R)^2}{H_L+H_R+\lambda}\right] - \gamma \$$

注意该特征收益也可作为特征重要性输出的重要依据。

对于每次分裂,我们都需要枚举所有特征可能的分割方案,如何高效地枚举所有的分割呢?

我假设我们要枚举所有 $x < a$ 这样的条件,对于某个特定的分割点 $a$ 我们要计算 $a$ 左边和右边的导数和。

我们可以发现对于所有的分裂点 $a$ ,我们只要做一遍从左到右的扫描就可以枚举出所有分割的梯度和 $G_L$ 和 $G_R$ 。然后用上面的公式计算每个分割方案的分数就可以了。

观察分裂后的收益,我们会发现节点划分不一定会使得结果变好,因为我们有一个引入新叶子的惩罚项,也就是说引入的分割带来的增益如果小于一个阀值的时候,我们可以剪掉这个分割。

2)近似算法

贪婪算法可以的到最优解,但当数据量太大时则无法读入内存进行计算,近似算法主要针对贪婪算法这一缺点给出了近似最优解。

对于每个特征,只考察分位点可以减少计算复杂度。

该算法会首先根据特征分布的分位数提出候选划分点,然后将连续型特征映射到由这些候选点划分的桶中,然后聚合统计信息找到所有区间的最佳分裂点。

在提出候选切分点时有两种策略:

  • Global:学习每棵树前就提出候选切分点,并在每次分裂时都采用这种分割;
  • Local:每次分裂前将重新提出候选切分点。

直观上来看,Local 策略需要更多的计算步骤,而 Global 策略因为节点没有划分所以需要更多的候选点。

下图给出不同种分裂策略的 AUC 变换曲线,横坐标为迭代次数,纵坐标为测试集 AUC,eps 为近似算法的精度,其倒数为桶的数量。

我们可以看到 Global 策略在候选点数多时(eps 小)可以和 Local 策略在候选点少时(eps 大)具有相似的精度。此外我们还发现,在 eps 取值合理的情况下,分位数策略可以获得与贪婪算法相同的精度。

  • 第一个 for 循环:对特征 k 根据该特征分布的分位数找到切割点的候选集合 $S_k={s_{k1},s_{k2},…,s_{kl} }$ 。XGBoost 支持 Global 策略和 Local 策略。
  • 第二个 for 循环:针对每个特征的候选集合,将样本映射到由该特征对应的候选点集构成的分桶区间中,即 ${s_{k,v}≥x_{jk}>s_{k,v−1}}$ ,对每个桶统计 $G,H $ 值,最后在这些统计量上寻找最佳分裂点。

下图给出近似算法的具体例子,以三分位为例:

根据样本特征进行排序,然后基于分位数进行划分,并统计三个桶内的 $G,H$ 值,最终求解节点划分的增益。

1.1.4 加权分位数缩略图

事实上, XGBoost 不是简单地按照样本个数进行分位,而是以二阶导数值 $h_i $ 作为样本的权重进行划分,如下:

那么问题来了:为什么要用 $h_i$ 进行样本加权?

我们知道模型的目标函数为:

$$ Obj^{(t)} \approx \sum_{i=1}^n \left[ g_if_t(x_i) + \frac12h_if_t^2(x_i) \right] + \sum_{i=1}^t \Omega(f_i) \$$

我们稍作整理,便可以看出 $h_i$ 有对 loss 加权的作用。

$$\begin{align} Obj^{(t)} & \approx \sum_{i=1}^n \left[ g_if_t(x_i) + \frac12h_if_t^2(x_i) \right] + \sum_{i=1}^t \Omega(f_i) \ \ &= \sum_{i=1}^{n} [ g_i f_t(x_i) + \frac{1}{2}h_i f_t^2(x_i) \color{red}{+ \frac{1}{2}\frac{g_i^2}{h_i}}]+\Omega(f_t) \color{red}{+ C} \ &= \sum_{i=1}^{n} \color{red}{\frac{1}{2}h_i} \left[ f_t(x_i) - \left( -\frac{g_i}{h_i} \right) \right]^2 + \Omega(f_t) + C \end{align} \$$

其中 $\frac{1}{2}\frac{g_i^2}{h_i}$ 与 $C$ 皆为常数。我们可以看到 $h_i$ 就是平方损失函数中样本的权重。

对于样本权值相同的数据集来说,找到候选分位点已经有了解决方案(GK 算法),但是当样本权值不一样时,该如何找到候选分位点呢?(作者给出了一个 Weighted Quantile Sketch 算法,这里将不做介绍。)

1.1.5 稀疏感知算法

在决策树的第一篇文章中我们介绍 CART 树在应对数据缺失时的分裂策略,XGBoost 也给出了其解决方案。

XGBoost 在构建树的节点过程中只考虑非缺失值的数据遍历,而为每个节点增加了一个缺省方向,当样本相应的特征值缺失时,可以被归类到缺省方向上,最优的缺省方向可以从数据中学到。至于如何学到缺省值的分支,其实很简单,分别枚举特征缺省的样本归为左右分支后的增益,选择增益最大的枚举项即为最优缺省方向。

在构建树的过程中需要枚举特征缺失的样本,乍一看该算法的计算量增加了一倍,但其实该算法在构建树的过程中只考虑了特征未缺失的样本遍历,而特征值缺失的样本无需遍历只需直接分配到左右节点,故算法所需遍历的样本量减少,下图可以看到稀疏感知算法比 basic 算法速度块了超过 50 倍。

1.2 工程实现

1.2.1 块结构设计

我们知道,决策树的学习最耗时的一个步骤就是在每次寻找最佳分裂点是都需要对特征的值进行排序。而 XGBoost 在训练之前对根据特征对数据进行了排序,然后保存到块结构中,并在每个块结构中都采用了稀疏矩阵存储格式(Compressed Sparse Columns Format,CSC)进行存储,后面的训练过程中会重复地使用块结构,可以大大减小计算量。

  • 每一个块结构包括一个或多个已经排序好的特征;
  • 缺失特征值将不进行排序;
  • 每个特征会存储指向样本梯度统计值的索引,方便计算一阶导和二阶导数值;

这种块结构存储的特征之间相互独立,方便计算机进行并行计算。在对节点进行分裂时需要选择增益最大的特征作为分裂,这时各个特征的增益计算可以同时进行,这也是 Xgboost 能够实现分布式或者多线程计算的原因。

1.2.2 缓存访问优化算法

块结构的设计可以减少节点分裂时的计算量,但特征值通过索引访问样本梯度统计值的设计会导致访问操作的内存空间不连续,这样会造成缓存命中率低,从而影响到算法的效率。

为了解决缓存命中率低的问题,XGBoost 提出了缓存访问优化算法:为每个线程分配一个连续的缓存区,将需要的梯度信息存放在缓冲区中,这样就是实现了非连续空间到连续空间的转换,提高了算法效率。

此外适当调整块大小,也可以有助于缓存优化。

1.2.3 “核外”块计算

当数据量过大时无法将数据全部加载到内存中,只能先将无法加载到内存中的数据暂存到硬盘中,直到需要时再进行加载计算,而这种操作必然涉及到因内存与硬盘速度不同而造成的资源浪费和性能瓶颈。为了解决这个问题,XGBoost 独立一个线程专门用于从硬盘读入数据,以实现处理数据和读入数据同时进行。

此外,XGBoost 还用了两种方法来降低硬盘读写的开销:

  • 块压缩:对 Block 进行按列压缩,并在读取时进行解压;
  • 块拆分:将每个块存储到不同的磁盘中,从多个磁盘读取可以增加吞吐量。

1.3 优缺点

1.3.1 优点

  1. 精度更高:GBDT 只用到一阶泰勒展开,而 XGBoost 对损失函数进行了二阶泰勒展开。XGBoost 引入二阶导一方面是为了增加精度,另一方面也是为了能够自定义损失函数,二阶泰勒展开可以近似大量损失函数;
  2. 灵活性更强:GBDT 以 CART 作为基分类器,XGBoost 不仅支持 CART 还支持线性分类器,(使用线性分类器的 XGBoost 相当于带 L1 和 L2 正则化项的逻辑斯蒂回归(分类问题)或者线性回归(回归问题))。此外,XGBoost 工具支持自定义损失函数,只需函数支持一阶和二阶求导;
  3. 正则化:XGBoost 在目标函数中加入了正则项,用于控制模型的复杂度。正则项里包含了树的叶子节点个数、叶子节点权重的 L2 范式。正则项降低了模型的方差,使学习出来的模型更加简单,有助于防止过拟合;
  4. Shrinkage(缩减):相当于学习速率。XGBoost 在进行完一次迭代后,会将叶子节点的权重乘上该系数,主要是为了削弱每棵树的影响,让后面有更大的学习空间;
  5. 列抽样:XGBoost 借鉴了随机森林的做法,支持列抽样,不仅能降低过拟合,还能减少计算;
  6. 缺失值处理:XGBoost 采用的稀疏感知算法极大的加快了节点分裂的速度;
  7. 可以并行化操作:块结构可以很好的支持并行计算。

1.3.2 缺点

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