XGBoost的成功主要归因于,各种场景下的可拓展性。
该系统在单机上的运行速度,比现存的其他方案快10倍。在分布式或内存配置下,可拓展支持数十亿的样本量。
XGBoost的可拓展性,源于系统和算法层面的优化。主要贡献,包括:
- 设计并构建了一个高可拓展的端到端的树提升系统,能在集群资源使用最少的情况下,处理更大的数据量。
- 引入了一种新型的稀疏感知的学习算法,以供并行树学习。
- 提出了一种带权分位方案,以供高效地进行候选分裂点的计算。
- 并行和分布式计算提高了学习的速度,加快了模型探索的进程。
- 提出了一种高效关注缓存的Block结构,以供核外树学习,支持在桌面上处理更多样本。
详细说明如下:
1. 缩减和列抽样(Shrinkage and Column Subsampling)
除了在目标函数中添加正则化项之外,另有两种方法可进一步避免训练过程中的过拟合:
一是,由Friedman提出的缩减。缩减,意味着在每轮树提升的迭代后,引入一个因子 η \eta η,对新加入的权重进行缩放。与随机优化中的学习率类似,缩减是减小了单颗树的影响,为未来加入的树在提升模型效果方面留出空间。
二是,列抽样。虽然该技术已广泛应用于随机森林,但还未曾在树提升中使用。相比于传统行抽样(XGBoost同样支持)而言,列抽样更容易避免过拟合。此外,列抽样也有助于提升并行算法的计算速度。
2. 分裂点的查找算法(Split Finding Algorithms)
2.1 精确贪心算法(Basic Exact Greedy Algorithm)
从(四)梯度提升的内容可以看到,树学习中最重要的问题就是,寻找能得到最大增益的分裂点。
为了解决这个问题,产生了一种遍历所有特征及所有可能分裂方式的分裂点查找算法,称作精确贪心算法。即,暴力穷举法。
现有的多数单机树提升的实现,都支持这种算法,如:scikit-learn、R’s gbm和单机版XGBoost。
其伪码如下:
![](https://i-blog.csdnimg.cn/blog_migrate/4c207f36970a42d62c5a0c22ad2d637d.png)
详细来说,分为以下几步:
1)输入现叶节点的实例集合
I
I
I和需要寻找最佳分裂切点的特征维度
d
d
d。
2)增益初始值为0,求实例集合
I
I
I中,各实例的一阶导数之和
G
G
G,各实例的二阶导数之和
H
H
H。
3)遍历所有特征,左子树的一阶、二阶导数之和的初始值分别为0。遍历该特征的排序特征值,分别计算分裂后,左、右子树的一阶、二阶导数之和,并求该分裂方式得到的增益。最终,得到增益最大的分裂特征及相应的分裂点。
4)以增益评分最大的方式分裂并输出。
精确贪心算法,需要进行大量的计算,以评估连续型特征的所有分裂方式。为了高效地实现这一点,该算法首先需要对数据按特征值进行排序,然后按照排序计算梯度统计值,得到结构分(即,分裂增益)。
2.2 近似算法(Approximate Algorithm)
精确贪心算法之所以如此强大,是因为它通过贪心的方式评估了所有可能的分裂点。然而,当数据无法完全载入内存时,该算法无法有效地实现。即使是分布式配置,也不能避免这个问题。
为了在单机和分布式两种配置下,都支持有效的梯度树提升,需要使用一种近似算法。
其伪码如下:
![](https://i-blog.csdnimg.cn/blog_migrate/dca9f4f5c83383c05dddc5308d342a17.png)
详细来说,分为以下几步:
1)遍历所有特征,根据每一个特征(如第 k 个特征)分布的分位数来决定
l
l
l个候选分裂点,组成集合
S
k
S_k
Sk。
2)根据候选分裂点把相应的样本映射到对应的桶中,对每个桶的一阶导数和二阶导数分别进行累加,得到
G
G
G和
H
H
H。最后,在候选分裂点集合上进行类似上述精确贪心算法的贪心查找。
图示如下:
![](https://i-blog.csdnimg.cn/blog_migrate/7c7c7a0444019d6ffc1508e3bd35350c.png)
近似算法,根据候选点的来源,可分为以下两种变体:
1)全局(Global)
在建树前预先将数据进行全局分桶。需要设置更小的分位数间隔(用 ϵ 表示,3分位的分位数间隔就是 1/3),产生更多的桶。特征分裂查找基于的候选点多,计算较慢。但只需在全局执行一次,即,全局分桶多次使用。
2)局部(Local)
每次分裂重新局部分桶。可设置更大的分位数间隔,产生更少的桶。每次特征分裂查找基于较少的候选点,计算速度快。但需要每次节点分裂后重新执行,可能更适合树深的场景。
2.3 算法对比
下图为,几种不同的分裂点查找算法,在Higgs boson数据集上的AUC效果对比:
![](https://i-blog.csdnimg.cn/blog_migrate/0f617fb8eb0fc55139e16e6ded2aa493.png)
通过对比可以得到,局部分桶的近似算法,所需桶数更少。对于全局分桶的近似算法而言,只要桶数够多,就能得到与局部分桶类似的效果。
最后,上述几种分裂点的查找算法(包括;精确贪心算法、全局分桶的近似算法、局部分桶的近似算法),XGBoost都有支持。用户可根据实际场景,自行选择。
3. 带权的分位方案(Weighted Quantile Sketch)
在近似算法中,如何决定候选分裂点是非常重要的一步。
通常使用特征的百分位数,从而使得候选分裂点能均匀地分布在数据集上。
假设有多元集合:
D k = { ( x 1 k , h 1 ) , ( x 2 k , h 2 ) … ( x n k , h n ) } D_k = \{(x_{1k}, h_1),\, (x_{2k}, h_2)\ldots(x_{nk}, h_n)\} Dk={(x1k,h1),(x2k,h2)…(xnk,hn)}
其中, x n k x_{nk} xnk、 h n h_n hn分别表示每个训练实例中的第k个特征值和二阶导数。
由此,可定义一个排序函数
r
k
r_k
rk:
r
k
(
z
)
=
1
Σ
(
x
,
h
)
∈
D
k
h
∑
(
x
,
h
)
∈
D
k
,
x
<
z
h
r_k(z) = \frac{1}{\Sigma_{(x,h)\in{D_k}}h}\sum_{(x,h)\in{D_k},x<z}h
rk(z)=Σ(x,h)∈Dkh1(x,h)∈Dk,x<z∑h
该函数表示特征值k小于值z的实例占比,其目的是查找一系列的候选分裂点 { s k 1 , s k 2 , … s k l } \{s_{k1},\,s_{k2},\,\ldots\,s_{kl}\} {sk1,sk2,…skl},满足:
∣ r k ( s k , j ) − r k ( s k , j + 1 ) ∣ < ϵ , s k 1 = m i n i x i k , s k l = m a x i x i k . |r_k(s_{k,j})-r_k(s_{k,j+1})| < \epsilon,\,s_{k1} = \mathop{min}\limits_{i}\bf{x}\it{_{ik}},\,s_{kl} = \mathop{max}\limits_{i}\bf{x}\it{_{ik}}. ∣rk(sk,j)−rk(sk,j+1)∣<ϵ,sk1=iminxik,skl=imaxxik.
即,控制相邻两个候选分裂点的差不超过某个值 ϵ \epsilon ϵ(近似因子,approximation factor) 。此时, 1 / ϵ 1/\epsilon 1/ϵ的整数值代表分位数。如 ϵ = 1 3 \epsilon=\frac{1}{3} ϵ=31 ,那么为三分位,有3-1=2个候选分裂点。
下图有助于更直观地进行理解:
![](https://i-blog.csdnimg.cn/blog_migrate/8edb411280d285a9db6fbdbe89768768.png)
此时 ϵ = 1 3 \epsilon=\frac{1}{3} ϵ=31,即,切分为3桶。 h i h_i hi总和为1.8,因此第1个分裂点在 h i = 0.6 h_i=0.6 hi=0.6 处,第2个在 h i = 1.2 h_i=1.2 hi=1.2 处。
此外,每一个数据点的权重为 h i h_i hi。具体推导如下:
![](https://i-blog.csdnimg.cn/blog_migrate/879103cc33c81b788eb59d4f69c63c17.png)
其中,第一行的初始公式,为(四)梯度提升中,通过泰勒展开近似得到的新目标函数。
因此,目标函数可视作,标签为 − g i h i -\frac{g_i}{h_i} −higi,权重为 h i h_i hi的平方误差。此时, g i 2 2 h i \frac{g^2_{i}}{2h_i} 2higi2 为常数。
4. 稀疏感知分裂点的查找(Sparsity-aware Split Finding)
在许多现实问题中,输入的特征 x 常为稀疏。
存在多种造成特征稀疏的情况:
1)数据缺失。
2)数据中包含大量统计量为0的值。
3)诸如“独热编码(one-hot encoding)”等特征工程处理。
因此,算法对于数据中的稀疏部分有所感知,显得尤为必要。XGBoost 提出了在每一个树节点增加默认分裂方向的方法,即,当稀疏矩阵 x 中的某个值发生缺失时,该实例会按照默认的方向进行分类。具体如下图所示:
![](https://i-blog.csdnimg.cn/blog_migrate/ba8224e5024d334365d1a36d51f5d349.png)
对于每一个分支,其默认分裂方向都有两种选择,需要通过对数据的学习,得到最优的默认分裂方向。该算法的伪码如下:
![](https://i-blog.csdnimg.cn/blog_migrate/b6d7bdc9bdf05f8cddb7168fdd141756.png)
具体可以概括为以下几步:
1)默认缺失值进右子树,对非缺失的实例,按特征值排序遍历,分别对左子树的一阶导数、二阶导数求和,得到
G
L
G_L
GL 与和
H
L
H_L
HL;而右子树的统计值分别为
G
−
G
L
G−G_L
G−GL与
H
−
H
L
H−H_L
H−HL。计算该种方案的结构分。
2)默认缺失值进左子树,对非缺失的实例,按特征值排序遍历,分别对右子树的一阶导数、二阶导数求和,得到
G
R
G_R
GR 与和
H
R
H_R
HR;而左子树的统计值分别为
G
−
G
R
G−G_R
G−GR与
H
−
H
R
H−H_R
H−HR。计算该种方案的结构分。
3)选择结构分(即增益)更大的方案。
其中,最大的改进在于,遍历特征值寻找候选分裂点时,仅针对非缺失的实例。
该算法相比于其他树学习算法的优势在于:其他算法,只支持处理稠密数据,或仅针对有限场景(如类别编码)进行一些特殊处理;而XGBoost,能统一对所有稀疏数据进行处理。
该方法利用稀疏性使计算复杂度与输入中的非缺失条目的数量成线性关系。在 Allstate-10K 数据集上,比传统方法快了50倍。
5. 系统设计
5.1 旨在平行学习的列块结构(Column Block for Parallel Learning)
在树学习中,时间耗费最多的部分,在于数据的排序。为了降低排序成本,XGBoost提出将数据存储在内存单元中,称之为“块”(Block)。
每个Block中的数据,是以压缩的稀疏列(Compressed Sparse Column, CSC)保存的,每一列按相应的特征值排序。该种设计仅需在训练前计算一次,就可以在之后的迭代中重复使用。
对于精确贪心算法,XGBoost将整个数据集储存在一个Block中,并对预先排序的输入进行线性扫描,以执行分裂点查找算法。在Block中,可以同时对所有叶子进行分裂点的计算。因此,仅需对整个Block进行一次扫描,就可以得到所有叶分支的分裂候选点。如下图所示:
![](https://i-blog.csdnimg.cn/blog_migrate/c52f41927f3ca173ce75ff3aa5e2d3b2.png)
上述的Block结构,同样适用于近似算法。这种情况下,会用到多个Block,即,每一个Block对应数据集的一个行子集。不同的Block可以分布在不同的机器上,或是在核外配置下储存在磁盘上。使用该种排序储存结构后,分位查找步骤即为对排序的列进行线性扫描。
寻找每一列分裂候选点的过程,是可以并行实现的。这意味着,分裂点的查找是一个并行的算法。此外,这种Block结构也支持列抽样。
5.2 关注缓存的存取(Cache-aware Access)
尽管Block结构优化了分裂点查找的计算复杂度,但因为需要通过行索引取得梯度统计值,而梯度的获取顺序是按照特征值排序的,由此将导致非连续的内存访问,可能使得CPU cache缓存命中率低,从而减慢分裂点查找的速度,进而影响算法效率。如下图所示:
![](https://i-blog.csdnimg.cn/blog_migrate/697e2dd88c55ac637053a126f8cc86b3.png)
为了解决上述问题,对于精确贪心算法,采用缓存预取(cache-aware prefetching)。具体来说,是对每个线程分配一个内部的缓冲区(internal buffer),以供提取梯度统计信息,再以小批量(mini-batch)的方法实现计算。这种预取方式,将直接读写依赖关系更改为更长的依赖关系。当行数较多时,有助于减少运行时间的开销。因此,尤其适用于训练集较大的场景。
对于近似算法,采用对Block大小进行合理配置的方式。定义Block的大小为一个Block中包含最多的样本数,因其直接反映了梯度统计值的缓存储存成本。Block的大小,若设置过大,容易导致命中率低,因为梯度统计值并不适合CPU缓存;若设置过小,则每个线程工作量过小,容易导致低效的并行化。实验发现,每个Block包含 2 16 2^{16} 216个样本,较为理想。
5.3 块的核外计算(Blocks for Out-of-core Computation)
XGBoost系统的设计宗旨之一,是希望充分利用机器的资源以实现可拓展的学习。除了处理过程和内存的优化之外,当数据量太大不能全部放入主内存时,对磁盘空间进行优化,也是十分必要的。
为了实现核外(out-of-core)计算,可以将数据分为多个Block,储存到磁盘上。计算时需要使用一个独立的线程,将Block预存至主内存缓存区(buffer)中,从而可以在计算的同时进行磁盘的读取。然而,该种方法的弊端在于,磁盘读取数据耗时过长,吞吐量不足。
XGBoost采用了2个策略来解决核外计算的问题:
一是,Block压缩(Block Compression)。将Block按列压缩(使用一种通用压缩算法对特征值进行压缩),当需要读入主内存时,再通过独立的线程进行即时解压。这有助于用磁盘读取的成本,交换一些解压的计算资源。
二是,Block分片(Block Sharding)。
以一种交替的方式,将数据分片至多个磁盘上。为每个磁盘分配一个预取(pre-fetcher)线程,供数据提取至内存缓冲区(in-memory buffer)。然后,训练线程可以交替地从每个缓冲区读取数据,从而在多个磁盘可用时,增加磁盘读取的吞吐量。