XGBoost,全称“Extreme Gradient Boosting”,和GBDT一样属于Boosting类型的模型,也是一种加法模型。
1 XGBoost原理
1.1 监督学习概念回顾
1.1.1模型和参数
监督学习中模型表示从样本输入到预测输出的映射关系,参数是我们要学习的部分,比如线性回归模型,是我们要学习的参数。
1.1.2 目标函数:损失+正则
模型训练的过程就是寻找最优参数,使得模型能够很好的拟合样本和标签。为了评价模型拟合样本和标签的好快程度,我们需要一个评估指标,即模型的目标函数。
通常目标函数由两部分组成:损失部分和正则部分:,其中L是损失函数,衡量模型的预测能力,是正则项,控制模型的复杂度,防止模型过拟合。
1.2 决策树加法模型
1.2.1 加法模型的训练Additive Training
XGBoost是基于CART决策树的加法模型,形式可以表示为:
其中K表示树的棵数,f是函数空间F中的函数,F代表所有可能的CART树。目标函数表示为
有了目标函数之后,XGBoost的训练过程就是寻找最优参数最小化目标函数。XGBoost的参数是什么呢?XGBoost和GBDT一样,是函数空间的加法模型,其参数为函数,每个函数表示一棵CART树,包含树的结构和树中叶子节点的分数。
XGBoost的加法模型表示为:
...
在每一步中,我们想要学习的函数或CART树必须能够使得目标函数最优:
使用展开到二阶的泰勒展开式
将目标函数展开到二阶项:
其中:
移除所有的常数项之后,在第t步的目标函数就变为:
这就是我们每学习一棵新决策树时的优化目标,该优化目标的值只依赖于和,这也就是为什么XGBoost支持自定义损失函数的原因:只要损失函数是一阶和二阶可导的,我们就可以一阶导数和二阶导数优化目标函数。
1.2.2 模型复杂度
目标函数中除了损失之外,还有一项:正则项,也就是说我们还需要定义决策树的复杂度。
首先,定义一棵决策树为:
其中是表示叶子节点的分数的向量,表示把每个样本映射到对应叶子节点的函数,是叶子节点的数量。
基于以上的决策树定义,XGBoost中决策树复杂度定义为:
当然,决策树复杂度的定义可以有多种,但是这里使用的定义方式在实践中比较有效。很多关于树模型的算法包中经常忽略正则项,是因为传统树的学习重点关注提高节点纯度。XGBoost通过在目标函数中增加决策树复杂度,we can get a better idea of what we are learning and obtain models that perform well in the wild.
1.2.3 模型训练
在定义了决策树复杂度之后,我们在第t步的目标函数可以进一步做如下变换:
其中是分配到叶子节点j的样本集合。由于同属一个叶子节点的样本具有相同的分数,所以可以进行以上的变换。
如果定义:
那么上面的目标函数可以简化为:
上面的目标函数中可以看做是关于的二次方程,那么使得目标函数取得最优值时的以及目标函数的最优值分别为:
对于一棵决策树,定义了叶子节点的分数,相当于树的纯度,并且包含了树复杂度的度量。
有了树的度量标准之后,理想情况下可以遍历所有可能的树结构,选择最优的结构。但是在实际中是行不通的,所以我们选择每次优化树的一层结构,即当我们需要把树的一个叶子节点分裂成两个的时候,分裂的Gain为:
Gain由四部分组成:1)分裂之后左边叶子节点的分数;2)分裂之后右边叶子节点的分数;3)分裂之前叶子节点的分数;4)一个叶子节点分裂为两个叶子节点时额外增加的正则。
从Gain的表达式可以看出,如果叶子节点分裂的分数差小于,就不要再进行分裂了。
Gain就是XGBoost在选择分裂点时的评估依据。在实际进行分裂时,为了比较高效的选择最优的分裂点,通常我们先把样本按照候选特征进行排序,然后进行遍历并计算每个可能分裂点的Gain。
2 XGBoost算法优化
2.1 Shrinkage and Column Subsampling
除了使用正则项之外,XGBoost还使用另外两种技术进一步防止过拟合。第一个是shrinkage,由Friedman提出(就是那个提出GBDT算法的Friedman)。Shrinkage对新学习到的树节点的分数进行缩放,类似于梯度优化中的学习率,shrinkage对单棵树的预测结果进行缩放,降低了单棵树的预测结果在模型中的权重,并且给以后的树留出了学习空间。第二个是列采样,列采样通常被用在RandomForest中。列采样相比行采样(也就是对样本进行采样,XGBoost也支持行采样),防止过拟合的效果更好,同时,列采样也一定程度上加速了XGBoost的并行计算。
2.2 最佳分裂点寻找算法
2.2.1 精确贪婪算法
树训练中的一个比较重要的问题就是寻找最佳分裂点。最基本的精确贪婪算法通过遍历所有可能的分裂点,然后计算Gain,选择Gain最大的分裂点。为了提高遍历效率,需要首先对样本按照特征值进行排序。精确贪婪算法的流程见Algorithm1。
2.2.2 近似算法
精确贪婪算法因为要遍历所有可能的分裂点,所以总是能够找到最佳的分裂点,但是当数据量足够大,超出内存容量的时候,这种方式就失效了。另外,在分布式计算的情况下,数据分布在不同的节点上,精确贪婪算法也不能进行有效的计算。在这两种情况下,就需要采用近似算法。近似算法首先根据特征值分布的百分位数产生一些候选分割点,然后根据候选分割点把连续的特征离散化,然后计算梯度统计gi和hi,根据梯度统计计算obj*和gain,进而寻找最佳候选分割点。详细的算法流程见Algorithm2.
根据候选分割点在什么时候产生,近似算法又分成两种形式。第一种是global variant,是在树构造之前首先产生所有的候选分割点,后面在树的每一层进行分裂的时候都使用这些候选分割点。另外一种是Local variant,是在每次节点分裂之后,根据分配到节点的样本重新产生候选分割点。Global variant只产生一次候选分割点,而local variant需要多次产生候选分割点,但是由于属于树中间层节点的样本较少,尤其是随着树的深度增加,local variant相比global variant产生的候选分割点数量更少,而且由于local variant产生候选分割点只依赖于当前节点的样本特征值,因此更适合于deeper tree。
为了对比global variant和local variant,我们在Higgs boson dataset(一个kaggle比赛的数据集)上对两种方式进行试验,试验结果见下图。
(说明:eps越小,说明产生的候选分割点越多,原因或原理见2.2.3)
试验结果:1)global eps=0.05和exact greedy对比说明,近似算法的global variant如果产生足够多数量的候选分裂点,基本达到和精确贪婪算法同样的模型效果;2)global eps=0.3和local eps=0.3对比说明,产生候选分裂点数量相同的情况下,local variant相比global variant能达到更好的模型效果(因为在树的非根节点,相同数量的候选分裂点的情况下,实际上local variant要比global variant划分的更加精细),或者反过来说也可以,要达到同样的模型效果,local variant相比global variant需要更少的候选分裂点。
2.2.3 加权的百分位数候选分裂点产生方法(weighted quantile sketch)
近似算法中重要的一步就是产生合理的候选分裂点,通常是根据特征值的百分位数点产生候选分割点,这样可以把特征值平均的分成若干个区间。实际上,XGBoost中采取的方法是,计算,其中表示第k个特征的第n个样本的值,表示目标函数关于的二阶导数,然后定义排序函数:
表示在中特征值小于z的样本加权占比,权重为h。
为什么把二阶导数作为权重呢?原因来源于目标函数表达式:
对以上表达式变换一下:
这个表达式格式可以看做是以为label,以为权重的加权平方损失。
定义排序函数的目标是寻找候选分裂点,候选分裂点必须满足:
这里是一个超参数(2.2.2中的eps就是指的这个超参数),直观上可以理解为,候选分裂点的数量大概为.
2.2.4 稀疏数据的最佳分裂点寻找算法
在实际应用中,最常见的是类型是稀疏类型的特征,就是特征里面可能包含大量的缺失值。为了使XGBoost能够适用大量缺失值的场景,在树分裂的时候,给缺失值一个默认的分裂方向(全部分裂到左子节点或者全部分裂到右子节点),默认的分裂方向是通过在训练数据上学习得到的。学习默认分裂方向的算法见Algorithm3。算法只遍历没有缺失的样本,然后把有缺失值的样本当做一个整体,通过全部将其分到左子节点或右子节点,比较分到左边或右边的效果更好,就把全部缺失值样本分到哪边。
大多数已有的树学习算法要么仅针对密集数据进行了优化,要么需要特定的过程来处理有限的情况,例如分类编码。 XGBoost以统一的方式处理所有稀疏模式。 更重要的是,我们的方法利用了稀疏性,使计算复杂度与输入中非缺失项的数量呈线性关系。
2.3 并行学习(Column Block for Parallel Learning)
XGBoost中最耗时的部分是对特征进行排序。为了提高排序的效率,我们采取事先把数据存储在被称为block的内存单元里,每个block中的数据是以列压缩(CSC)的格式存储,每列都按照该列的数值大小进行排序。这种输入数据只需要在训练之前计算一次,就可以重复用于之后每棵树的迭代训练。
在精确贪婪算法中,我们把整个数据集存储在同一个block中,并且存储在block中的数据已经是按照每列的特征值进行排序后的,所以只需要遍历一次block,并且根据每个样本点属于哪个叶子节点,就能同时为每个叶子节点寻找到最佳分裂点。下图表示了如何把数据集转换成列压缩(CSC)的格式,并且使用block高效的完成寻找最佳分裂点。
在近似算法寻找最佳分裂点中, 采取的block存储方式与精确贪婪算法中有所不同,近似算法中采取用多个block对数据进行存储,每个block对应于一部分样本数据,并且block可以分布在不同的机器上,或存储在磁盘上。因为每个block是根据特征值预先排序的(此处,我理解应该是整体数据集先排序之后,然后分散在不同的block中),近似算法中的分位数搜索就变成了线性搜索,尤其是在local proposal模式下,候选分割点需要在每个分支下搜索产生,线性搜索相比分位数搜索极大的提高了效率。
收集每列的梯度统计信息可以并行进行,从而为我们提供了一种候选分裂点查找的并行算法。另外重要的是,CSC列压缩存储格式和block存储结构支持列采样,所以可以很方便的选择block中的部分列。
2.4 Cache-aware Access缓存感知访问
block结构降低了选取候选分裂点的计算复杂度,但是由于block中的数据是按照CSC的列压缩格式存储的,并且每列是按照特征值排序的,除了特征值之外,还存储了指针从特征值指向样本索引,所以在选取候选分割点的时候,会不断的根据特征值对应的样本索引去取梯度统计值,也就是说CPU会不断地访问非连续的内存单元,增加CPU的负担。最简单的实现方法是,CPU读取一次内存单元中的梯度统计值,就进行一次累计计算,就回引起CPU在累计计算和访问非连续的内存单元之间的读写依赖关系。如果block中的梯度统计数据不能进入CPU缓存(比如block太大等原因),或者发生缓存丢失cache miss,就会降低候选分裂点选取的效率。
对于精确的贪婪算法,我们可以通过缓存感知的预提取算法来缓解问题。 具体来说,我们给每个线程分配一个内部缓冲区,将梯度统计信息提取到其中,然后以小批量的方式执行累加。 这种预提取将直接读/写依赖关系更改为更长的依赖关系,并在行数较大时帮助减少运行开销。在数据集较大时,精确贪婪算法的可感知缓存的运行速度是普通版本的两倍。
对于近似算法,我们通过选择合适的block大小来解决该问题。 我们将block大小定义为一个block中包含的最大样本数,因为这反映了梯度统计信息的缓存存储成本。 选择过小的block大小会较小每个线程的工作量,但是同时也会降低并行化效率。 另一方面,太大的block会导致高速缓存丢失cache miss,因此 block大小的选择应该要平衡这两个因素。 通过在不同数据集上的测试,block大小设置为是个合适的选择。
2.5 Block for Out-of-core Computation核外计算
为了充分利用机器的资源来实现可扩展的学习,除了处理器和内存之外,利用磁盘空间来处理不能放在主内存的数据也很重要。 为了实现核外计算,我们将数据分为多个block,并将每个block存储在磁盘上。 在计算过程中,重要的是要使用独立的线程将block预提取到主存储器缓冲区中,这样就可以在读取磁盘的同时进行计算。 但是,磁盘读取占用了大部分计算时间,所以要减少开销并增加磁盘IO的吞吐量。 我们主要使用两种技术来改进核外计算。
1)block压缩
将block按列进行压缩,然后在加载到内存时通过独立的线程解压缩,相当于用解压缩时间换取磁盘读取成本。我们使用通用压缩算法来压缩特征值。 对于行索引,我们把block中每个样本的索引值都减去开始索引,并使用16位整数存储每个样本的索引偏移量。 在我们测试的大多数数据集中,我们实现了大约26%至29%的压缩率。
2)block分片
将block中的数据分片到多个磁盘上,并且给每个磁盘分配独立的线程,将数据提前取入内存缓冲区中,然后,训练线程可以从每个缓冲区读取数据。 当有多个磁盘可用时,这有助于增加磁盘读取的吞吐量。