XGBoost
参考视频:春暖花开Abela
一、XGBoost 简介
\(XGBoost\) 全称 \(Extreme\ Gradient\ Boosting\),是 \(Gradient\ Boosting\) 的 \(C\)++ 优化实现。
二、XGBoost 原理
1. 从目标函数开始,生成一棵树
\(XGBoost\)、\(GBDT\) 都是 \(Boosting\) 方法,最大的不同就是目标函数的定义。
1.1 学习第 t 颗树
\(XGBoost\) 是由 \(k\) 个基模型组成的一个加法模型,假设第 \(t\) 次迭代要训练的树模型是 \(f_t(x)\),则有
其中,\(\hat{y}_i^{(t)}\) 表示第 \(t\) 次迭代后样本 \(i\) 的预测结果,\(\hat{y}_i^{(t-1)}\) 表示前 \(t-1\) 颗树的预测结果,\(f_t(x_i)\) 表示第 \(t\) 颗树的模型。
1.2 XGBoost 的目标函数
损失函数可由预测值 \(\hat{y}_i\) 与真实值 \(y_i\) 表示:
其中,\(n\) 为样本数量。
模型的预测精度由模型的偏差和方差共同决定,损失函数代表模型的偏差,想要方差小则需要在目标函数中添加正则项,用于防止过拟合。所以目标函数由模型的损失函数 \(L\) 与抑制模型复杂度的正则项 \(\Omega\) 组成,目标函数的定义如下:
其中,\(\sum_{i=1}^{t} \Omega(f_i)\) 是将全部 \(t\) 棵树的复杂度进行求和,添加到目标函数中作为正则化项,用于防止模型过度拟合。
由于 \(XGBoost\) 是 \(Boosting\) 族的算法,所以遵循前项分布算法,以第 \(t\) 步模型为例,模型对第 \(i\) 个样本 \(x_i\) 的预测值为:
其中,\(\hat{y}_i^{(t-1)}\) 是由第 \(t-1\) 步的模型给出的预测值,是已知常数,\(f_t(x_i)\) 是这次需要加入的新模型的预测值。此时,目标函数可以写成:
注意上式中,只有一个变量,那就是第 \(t\) 棵树 \(f_t(x_i)\),其余都是已知量或可通过已知量可以计算出来的。
上式中第二行到第三行如何得到的?这里将正则化项进行拆分,由于前 \(t-1\) 棵树的结构已经确定,因此前 \(t-1\) 棵树的复杂度之和可以用一个常量表示,如下:
1.3 泰勒公式展开
泰勒公式是将一个在 \(x = x_0\) 处具有 \(n\) 阶导数的函数 \(f(x)\) 利用关于 \((x-x_0)\) 的 \(n\) 次多项式来逼近函数的方法。若函数 \(f(x)\) 包含 \(x_0\) 的某个闭区间 \([a,b]\) 上具有 \(n\) 阶导数,且在开区间 \((a,b)\) 上具有 \(n+1\) 阶导数,则对闭空间 \([a,b]\) 上任意一点 \(x\) 有:
其中的多项式称为函数在 \(x_0\) 处的泰勒展开式,\(R_n(x)\) 是泰勒公式的余项且是 \((x-x_0)^n\) 的高阶无穷小。
根据泰来公式,把函数 \(f(x+\bigtriangleup x)\) 在点 \(x\) 处进行泰勒的二阶展开,可得:
回到 \(XGBoost\) 的目标函数上来,\(f(x)\) 对应损失函数 \(l(y_i, \ \ \hat{y}_i^{(t-1)}+f_t(x_i))\),\(x\) 对应前 \(t-1\) 棵树的预测值 \(\hat{y}_i^{(t-1)}\),\(\bigtriangleup x\) 对应于我们正在训练的第 \(t\) 棵树 \(f_t(x_i)\),则可以将损失函数写为:
其中,\(g_i\) 为损失函数的一阶导,\(h_i\) 为损失函数的二阶导,注意这里的求导是对 \(\hat{y}_i^{(t-1)}\) 求导。
我们以平方损失函数为例:
则:
将上述的二阶展开式,带入到 \(XGBoost\) 的目标函数中,可以得到目标函数的近似值:
由于在第 \(t\) 步时 \(\hat{y}_i^{(t-1)}\) 其实是一个已知的值,所以 \(l(y_i,\ \ \hat{y}_i^{(t-1)})\) 是一个常数,其对函数的优化不会产生影响。
因此,去掉全部的常数项,得到目标函数为:
所以我们只需要求出每一步损失函数的一阶导和二阶导的值(由于前一步的 \(\hat{y}^{(t-1)}\) 是已知的,所以这两个值就是常数),然后最优化目标函数,就可以得到每一步的 \(f(x)\),最后根据加法模型得到一个整体模型。
1.4 定义一棵树
\(XGBoost\) 的基模型不仅支持决策树,还支持线性模型,本文主要介绍基于决策树的目标函数。
我们重新定义一棵树,包括两部分:
- 叶子结点的权重向量 \(\omega\);
- 实例(样本)到叶子结点的映射关系 \(q\)(本质是树的分支结构)
1.5 定义树的复杂度
决策树的复杂度 \(\Omega\) 可由叶子数 \(T\) 组成,叶子结点越少模型越简单,此外叶子结点不应该含有过高的权重 \(w\)(类别\(LR\) 的每个变量的权重),所以目标函数的正则项由生成的所有决策树的叶子结点数量和所有结点权重所组成的向量的 \(L_2\) 范式(权重的平方和)共同决定。
1.6 叶子结点归组
\(I_j = \{i|q(x_i) = j\}\) 表示将第 \(j\) 个叶子结点的所有样本 \(x_i\) 划入到一个集合 \(I_j\) 中,那么 \(XGBoost\) 的目标函数可以写成:
第二行到第三行:第二行是遍历所有的样本后求每个样本的损失函数,但样本最终会落在叶子结点上,所以我们也可以遍历叶子结点,然后获取叶子结点上的样本集合,最后求损失函数。即之前是单个样本,现在改成叶子结点的集合,由于一个叶子结点有多个样本存在,因此有 \(\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\) :叶子结点 \(j\) 所包含样本的一阶偏导数累加之和,是一个常量;
- \(H_j = \sum_{i \in I_j} h_i\):叶子结点 \(j\) 所包含样本的二阶偏导数累加之和,是一个常量;
将 \(G_j\)、\(H_j\) 带入 \(XGBoost\) 目标函数:
\(G_j\)、\(H_j\) 是前 \(t-1\) 步得到的结果,其值已知可视为常数,只有最后一棵树的叶子结点 \(w_j\) 不确定。
1.7 树结构打分
假设一元二次函数:
根据最值公式求出最值点:
回到 \(XGBoost\) 的最终目标函数 \(Obj^{(t)}\),如何求它的最值?
分析上面的式子:
① 对于每个叶子结点 \(j\),可以将其从目标函数中拆解出来:
在 \(1.5\) 节中,\(G_j\)、\(H_j\) 相对于第 \(t\) 棵树来说可以计算出来,那么,这个式子就是一个只包含一个变量的叶子结点权重 \(w_j\) 的一元二次函数,我们可以通过最值公式求出它的最值点。
② 再次分析目标函数 \(Obj^{(t)}\),可以发现,各个叶子结点的目标子式是相互独立的,也就是说,当每个叶子结点的子式都达到最值点时,整个目标函数 \(Obj^{(t)}\) 才达到最值点。
那么,假设目前树的结构已经固定,套用一元二次函数最值公式,将目标函数对 \(w_j\) 求一阶导,并令其等于 \(0\),则可求得到叶子结点 \(j\) 对应的权值:
所以目标函数化简为:
上图给出目标函数计算的例子,求每个结点每个样本的一阶导数 \(g_i\) 和 二阶导数 \(h_i\),然后针对每个结点对所含样本求和得到 \(G_j\)、\(H_j\),最后遍历决策树的结点即可得到目标函数。
2. 一棵树的生成细节
2.1 最优切分点划分算法
在实际训练过程中,当建立第 \(t\) 棵树时,一个非常关键的问题是如何找到叶子结点的最优切分点,\(XGBoost\) 支持两种分裂结点的方法——贪心算法、近似算法。
① 贪心算法
- 从树深度为 \(0\) 开始,对每个结点枚举所有可用特征;
- 针对每个特征,把属于该结点的训练样本根据该特征值进行升序排列;
- 选择最佳分裂特征,最佳分裂点,将该结点上分裂出左右两个新的叶结点,并为每个新结点关联对应的样本集;
- 回到第 \(1\) 步,递归执行直到满足停止条件为止。
计算每个特征的分裂收益
假设在某结点完成特征分裂,则分裂前的目标函数为:
分裂后的目标函数为:
对目标函数来说,分裂后的收益为:
该特征收益可作为特征重要性输出的依据。
对于每次分裂,都需要枚举所有特征可能的分割方案,如何高效地枚举所有的分割?
假设我们要枚举某个特征所有 \(x<a\) 这样条件的样本,对于分割点 \(a\) 计算左边和右边的导数和。
对于一个特征,对特征取值排完序后,枚举所有的分裂点 \(a\),只要从左到右扫描就可以枚举出所有分割的梯度 \(GL\)、\(GR\),计算增益。假设树的高度 \(H\),特征数 \(d\),则复杂度为 \(O(Hdnlogn)\)。其中,排序为 \(O(nlogn)\),每个特征都要排序乘以 \(d\),每层都要排序乘以 \(H\)。
观察分裂后的收益,我们会发现结点划分不一定会使得结果变好,因为我们有一个引入新叶子的惩罚项,也就是说引入的分割带来的增益如果小于一个阈值的时候,我们可以剪掉这个分割。
② 近似算法
贪心算法可以得到最优解,但当数据量太大则无法读入内存进行计算,近似算法主要针对贪心算法这一缺点给出了近似最优解。
对于每个特征,只考察分位点可以减少计算复杂度。
该算法首先根据特征分布的分位数提出候选划分点,然后将连续型特征映射到由这些候选点划分的桶中,然后聚合统计信息找到所有区间的最佳分裂点。
在提出候选切分点时的两种策略:
- \(Global\):学习每棵树前就提出候选切分点,并在每次分裂时都采用这种分割;
- \(Local\):每次分裂前重新提出候选切分点。
直观上看,\(Local\) 策略需要更多的计算步骤,而 \(Global\) 策略因为结点已有划分所以需要更多的候选点。
下图给出不同种分裂策略的 \(AUC\) 变化曲线,横坐标为迭代次数,纵坐标为测试集 \(AUC\),eps
为近似算法的精度,其倒数为桶的数量。
从上图看出,\(Global\) 策略在候选点多时(eps
小)可以和 \(Local\) 策略在候选点少时(eps
大)具有相似的精度。此外还发现,eps
取值合理的情况下,分位数策略可以获得与贪心算法相同的精度。
近似算法:根据特征 \(k\) 的分布来确定 \(l\) 个候选切分点 \(S_k = \{s_{k1},s_{k2},...,s_{kl}\}\),然后根据候选切分点把相应的样本放入对应的桶中,对每个的 \(G,H\) 进行累加,在候选切分点集合上进行精确贪心查找。算法描述:
\(Algorithm\ 2:Approximate\ Algorithm\ for\ Split\ Finding\)
\(for\ k=1\ to\ m\ do\)
$\ \ \ \ $ \(Propose\ S_k = \{s_{k1},s_{k2},...,s_{kl}\}\ by\ percentiles\ on\ feature\ k.\)
$\ \ \ \ $ \(Proposal\ can\ be\ done\ per\ tree\ (global),\ or\ per\ split(local).\)
\(end\)
\(for\ k=1\ to\ m\ do\)
$\ \ \ \ $ \(G_{kv} \gets=\ \sum_{j \in \{j|s_{(k,v)} \ \ \geqslant x_{jk} \ > s_{(k,v-1) \ \ }\ \ \}}\ g_i\)
$\ \ \ \ $ \(H_{kv} \gets=\ \sum_{j \in \{j|s_{(k,v)} \ \ \geqslant x_{jk} \ > s_{(k,v-1) \ \ }\ \ \}}\ h_i\)
\(end\)
\(Follow\ same\ step\ as\ in\ previous\ section\ to\ find\ max\ score\ only\ among\ proposed\ splits.\)
算法讲解:
- 第一个 \(for\) 循环:对特征 \(k\) 根据该特征分布的分位数找到切割点的候选集合 \(S_k = \{s_{k1},s_{k2},...,s_{kl}\}\)。这样做的目的是提取出部分的切分点不用遍历所有的切分点。其中获取某个特征 \(k\) 的候选切割点的方式叫 \(proposal\)(策略)。\(XGBoost\) 支持 \(Global\) 策略和 \(Local\) 策略。
- 第二个 \(for\) 循环:将每个特征的取值映射到由该特征对应的候选点集划分的分桶区间,即 \(s_{(k,v)} \geqslant x_{jk} > s_{(k,v-1)}\)。对每个桶区间内的样本统计值 \(G,H\) 并进行累加,最后在这些累计的统计量上寻找最佳分裂点。这样做的目的是获取每个特征的候选分割点的 \(G,H\) 值。
实例:近似算法举例,以三分位为例。
根据样本特征进行排序,然后基于分位数进行划分,并统计三个桶内的 \(G,H\) 值,最终求结点划分的增益。
2.2 加权分位数缩略图
\(XGBoost\) 不是简单地按照样本个数进行分位,而是以二阶导数值 \(h_i\) 作为样本的权重进行划分。为了处理带权重的候选切分点的选取,提出了加权分位数缩略图算法。
加权分位数缩略图算法提出了一种数据结构,这种数据结构支持 \(merge\) 和 \(prune\) 操作。
加权分位数缩略图候选点的选取方式:
为什么用二阶梯度 \(h_i\) 进行样本加权?
模型的目标函数:
把目标函数整理成一下形式,可以看出 \(h_i\) 对 \(loss\) 加权的作用。
其中,加入 \(\frac{1}{2} \frac{g_{i}^{2}}{h_i}\) 是因为 \(g_i\)、\(h_i\) 是上一轮的损失函数求导与 \(constant\) 皆为常数。我们可以看到 \(h_i\) 就是平方损失函数中样本的权重。
2.3 稀疏感知算法
实际工程中,比如数据的缺失、\(one-hot\) 编码都会造成输入数据稀疏。\(XGBoost\) 在构建树的结点过程中只考虑非缺失值的数据遍历。
为每个结点增加了一个缺省方向,当样本相应的特征值缺失时,可以被归类到缺省方向上,最优的缺省方向可以从数据中学到。至于如何学到缺省值的分支,其实很简单,分别枚举特征缺省值的样本归为左右分支后的增益,选择增益最大的枚举项为最优缺省方向。
在构建树的过程中需要枚举特征缺失的样本,乍一看这个算法会多出相当于一倍的计算量,但其实不是的。因为在算法的迭代中只考虑了非缺失值数据的遍历,缺失值数据直接被分配到左右结点,所需要遍历的样本量大大减小。
通过在 Allstate-10K
数据集上进行实验,从结果看到稀疏算法比普通算法在数据处理上快了 \(50\) 倍。
三、XGBoost 工程实现
1. 列块并行学习
在树生成过程中,最耗时的是在每次寻找最佳分裂点时对特征的值进行排序。而 \(XGBoost\) 在训练之前会根据特征对数据进行排序,然后保存到块结构中,并在每个块结构中采用稀疏矩阵存储格式进行存储。后面的训练过程会重复使用块结构,大大减少计算量。
作者提出通过按特征进行分块并排序,在块里面保存排序后的特征值及对应样本的引用,以便获取样本的一阶、二阶导数值。具体方式:
通过顺序访问排序后的块,遍历样本特征的特征值,方便进行切分点的查找。此外分块存储后多个特征之间互不干涉,可以使用多线程同时对不同的特征进行切分点查找,即特征的并行化处理。在对结点进行分裂时需要选择增益最大的特征作为分裂,这时各个特征的增益计算可以同时进行,这也是 \(XGBoost\) 能够实现分布式或多线程计算的原因。
2. 缓存访问
列快并行学习的设计可以减少结点分裂时的计算量,在顺序访问特征值时,访问的是一块连续的内存空间,但通过特征值持有的索引(样本索引)访问样本获取一阶、二阶导数时,这个访问操作访问的内存空间并不连续,这样可能造成 \(CPU\) 缓存命中率低,影响算法效率。
为了解决缓存命中率低的问题,\(XGBoost\) 提出缓存访问方法:为每个线程分配一个连续的缓存区,将需要的梯度信息存放在缓冲区,这样就实现了非连续空间到连续空间的转换,提高了算法效率。此外适当调整块大小,也有助于缓存优化。
3. "核外" 块计算
当数据量非常大时,我们不能把所有的数据都加载到内存中。那么就必须将一部分需要加载进内存的数据先存放在硬盘中,当需要时再加载进内存。这样操作具有很明显的瓶颈,即硬盘的IO操作速度远远低于内存的处理速度,肯定会存在大量等待硬盘IO操作的情况。针对这个问题作者提出了“核外”计算的优化方法。具体操作为,将数据集分成多个块存放在硬盘中,使用一个独立的线程专门从硬盘读取数据,加载到内存中,这样算法在内存中处理数据就可以和从硬盘读取数据同时进行。此外,XGBoost 还用了两种方法来降低硬盘读写的开销:
- 块压缩(\(Block\ Compression\))。论文使用的是按列进行压缩,读取的时候用另外的线程解压。对于行索引,只保存第一个索引值,然后用 \(16\) 位的整数保存与该 \(block\) 第一个索引的差值。作者通过测试在 \(block\) 设置为 \(2^{16}\) 个样本大小时,压缩比率几乎达到 \(26\% \sim 29\%\)。
- 块分区(\(Block\ Sharding\) )。块分区是将特征 \(block\) 分区存放在不同的硬盘上,以此来增加硬盘 \(IO\) 的吞吐量。
四、XGBoost 的优缺点
1. 优点
- 精度更高:\(GBDT\) 只用到一阶泰勒展开,而 \(XGBoost\) 对损失函数进行了二阶泰勒展开。\(XGBoost\) 引入二阶导一方面为了增加精度,另一方面为了能够自定义损失函数,二阶泰勒展开可以近似大量损失函数。
- 灵活性更强:\(GBDT\) 以 \(CART\) 作为基分类器,\(XGBoost\) 不仅支持 \(CART\) 还支持线性分类器,使用线性分类器的 \(XGBoost\) 相当于带 \(L1\)、\(L2\) 正则化的逻辑斯蒂回归(分类问题)或线性回归(回归问题)。此外,\(XGBoost\) 工具支持自定义损失函数,只需要函数支持一阶、二阶求导。
- 正则化:\(XGBoost\) 在目标函数中加入正则项,用于控制模型的复杂度。正则项包含了树的叶子结点个数、叶子结点权重的 \(L2\) 范式。正则项降低了模型的方差,使学习出来的模型更简单,有助于防止过拟合,这也是 \(XGBoost\) 优于传统 \(GBDT\) 的一个特性。
- \(Shrinkage\)(缩减):相当于学习速率。\(XGBoost\) 在进行完一次迭代后,会将叶子结点的权重乘上该系数,主要为了削弱每棵树的影响,让后面有更大的学习空间。传统 \(GBDT\) 的实现也有学习速率。
- 列抽样:\(XGBoost\) 借鉴了随机森林的做法,支持列抽样,即特征的随机选择,不仅能降低过拟合,还能减少计算。这也是异于 \(GBDT\) 的一个特性。
- 缺失值处理:对特征值有缺失的样本,\(XGBoost\) 采用稀疏感知算法,可以自动学习出它的分裂方向。
- \(XGBoost\) 工具并行支持:\(Boosting\) 系列是一种串行结构,所以 \(XGBoost\) 的并行不是 \(tree\) 粒度的并行,\(XGBoost\) 也是一次迭代完才能进行下一次迭代的(第 \(t\) 次迭代的代价函数里包含了前面 \(t-1\) 次迭代的预测值)。\(XGBoost\) 的并行是在特征粒度上的。我们知道,决策树的学习最耗时的一个步骤就是对特征的值进行排序(因为要确定最佳分割点),\(XGBoost\) 在训练之前,预先对数据进行了排序,然后保存为 \(block\) 结构,后面的迭代中重复地使用这个结构,大大减小计算量。这个 \(block\) 结构也使得并行成为了可能,在进行节点的分裂时,需要计算每个特征的增益,最终选增益最大的那个特征去做分裂,那么各个特征的增益计算就可以开多线程进行。
- 可并行的近似算法:树结点在进行分裂时,我们需要计算每个特征的每个分割点对应的信息增益,即贪心算法枚举所有可能的分割点。当数据无法一次载入内存或者在分布式情况下,贪心算法效率会变低,所以 \(XGBoost\) 提出了一种可并行的近似算法,用于高效地生成候选分割点。
2. 缺点
- 虽然利用预排序和近似算法可以降低寻找最佳分裂点的计算量,但在结点分裂过程中仍需要遍历数据集。
- 预排序过程的空间复杂度过高,不仅需要存储特征值,还需要存储特征对应样本的梯度统计值的索引,相当于消耗两倍的内存。
五、XGBoost 面试
1. XGBoost 与 GBDT 的区别和联系?
① \(GBDT\) 是机器学习算法,\(XGBoost\) 是算法的工程实现。
② 正则项:在使用 \(CART\) 作为基分类器时,\(XGBoost\) 显示地加入了正则项来控制模型的复杂度,有利于防止过拟合,从而提高模型的泛化能力。
③ 导数信息:\(GBDT\) 在模型训练时只使用了损失函数的一阶导数信息,\(XGBoost\) 对损失函数进行二阶泰勒展开,可以同时使用一阶、二阶导数。
④ 个体学习器:\(GBDT\) 采用 \(CART\) 分类树作为个体学习器,\(XGBoost\) 支持多种类型的个体学习器,比如:线性分类器。
⑤ 子采样:\(GBDT\) 在每轮迭代时使用全部的数据,\(XGBoost\) 采用了与随机森林相似的策略,支持对数据进行采样。
⑥ 缺失值处理:\(GBDT\) 没有设计对缺失值的处理,\(XGBoost\) 能够自动学习出对缺失值的处理策略。
⑦ 并行化:\(GBDT\) 没有并行化设计,注意不是 \(tree\) 维度的并行,而是特征维度的并行。\(XGBoost\) 预先将每个特征按特征值排好序,存储为块结构,分裂结点时可以采用多线程并行查找每个特征的最佳分割点,极大提升训练速度。
2. 为什么 XGBoost 泰勒二阶展开后效果比较好?
① 为什么想到引入泰勒二阶展开(可扩展性):
当目标函数是均方差(\(MSE\))时,展开是一阶项(残差) + 二阶项的形式;
而其他目标函数,当为 \(Logistic\ loss\) 时,展示式不是上面的形式,为了统一形式,采用泰勒二阶展开得到二阶项。即为了统一损失函数求导的形式以支持自定义损失函数。
为什么在形式上与 \(MSE\) 统一?
\(MSE\) 是最普遍常用的损失函数,更容易求导,求导后的形式简单。所以理论上与 \(MSE\) 统一。
② 为什么引入泰勒二阶展开(精确性):
可以简单认为一阶导指引梯度方向,二阶导指引梯度方向如何变化。
二阶导能让梯度收敛更快更准确。这一点在牛顿法中已被证实。即 \(XGBoost\) 采用二阶泰勒展开,更逼近真实的损失函数。
3. XGBoost 对缺失值怎么处理?
\(GBDT\) 中,是手动填充缺失值,这种人工填充不一定准确。
\(XGBoost\)中, 是先不处理缺失值样本,采用有值的样本得到分裂点,然后在遍历每个有值特征的时候,尝试将缺失值样本划入左子树和右子树,选择使损失函数最优的值作为分裂点。
4. XGBoost 为什么可以进行并行训练?
① \(XGBoost\) 的并行,不是说每颗树可以并行训练,\(XGBoost\) 本质上仍然是 \(Boosting\) 思想,每颗树训练前需要等前面的树训练完成才开始训练。
② \(XGBoost\) 的并行,指的是特征维度的并行。在训练前,每个特征按特征值对样本进行预排序,并存储为 \(Block\) 结构,在后面查找特征分割点时可以重复使用,而且特征已被存储为一个个 \(Block\) 结构,那么在寻找每个特征的最佳分割点时,可以利用多线程对每个 \(Block\) 并行计算。