LightGBM(Light Gradient Boosting Machine)是梯度提升框架下的适用于大规模数据的又一boosting学习框架,它由微软亚洲研究院分布式机器学习工具包(DMTK)团队以及北大的一个学者在2017年NIPS大会上正式发表,并由微软提供开源。本篇博客是在精读了原始论文《LightGBM: A Highly Efficient Gradient Boosting Decision Tree》并参考了LightGBM原论文阅读翻译后对LightGBM的原理阐述一下个人的理解,既是自己消化吸收的过程,也提供给有需要的伙伴。
原始论文中说到“ We call our new GBDT implementation with GOSS and EFB LightGBM.”,这里涉及到两个算法GOSS和EFB(后面会专门讲到),但是在精读完论文并看了LightGBM官方文档、别人对LightGBM的总结1、总结2、总结3以及原作者之一Taifeng Wang的分享视频(小哥哥声音好温油的~)后,我认为这两点其实严格意义上说不算是LightGBM的基础,不过确实是亮点所在。我个人觉得原始论文对LightGBM的基础算法特别是直方图算法的细节以及按照Leaf-wise的方式生长的决策树讲的太少,因此这里会按照我自己的理解来重新梳理LightGBM。
总的来说,我个人的理解是:LightGBM是在GBDT的框架下,优化了基学习器也就是决策树的分割点寻找过程以及树的生长方式,即,使用直方图(Histogram)算法加速分割点寻找过程以及降低内存消耗,并使用带深度限制的Leaf-wise/Best-first的叶子生长策略提高基学习器精度并且更加高效地生成决策树。在这个基础上建立了LightGBM的基础——lgb_baseline。然后再分别从减少样本和减少特征的角度提出两个算法:基于梯度的单侧采样(Gradient-based One-Side Sampling,简称GOSS)来重点关注梯度较大的样本;以及互斥特征捆绑(Exclusive Feature Bundling,简称EFB)来降低在稀疏数据下互斥的特征数量。这两个算法将会在不损失精度的情况下大大加速训练过程,节约内存消耗,这也是这个算法称之为“Light”GBM的原因。
因此,这篇博客会先对直方图算法以及Best-first(Leaf-wise)决策树进行学习,然后再讨论GOSS和EFB。
关于直方图算法,原论文中描述的太简洁,而且给出的伪代码我没看明白,所以这里按照后来Taifeng Wang的分享视频中讲解的来理解。
与之前介绍过的XGBoost的加权分位数草图类似,直方图算法也是在决策树寻找分割点的过程中进行优化,减少大量数据的重复遍历。具体来说,直方图算法首先将每个特征的连续数据分割到K个bin中,即每个bin中分得一定数量的数据,由此原始的连续数据就变成了离散的bin数据,如下图(每一行格子表示一个特征,橘色的表示每个特征都变为了那个特征的bin数据):
在得到了这样的bin数据后,下一步就是利用这K个离散的bin构建K个特征直方图,从下面的伪代码可以看出,遍历所有数据,每个bin.g就是这个bin中所有数据的所有一阶梯度求和,bin.n就是这个bin中所有数据的数量,如此得到所有特征中每个bin的值。
接下来就是基于特征的直方图寻找决策树的最佳分割点。因为在GBDT的训练过程中绝大部分时间都花在遍历所有可能的分割点来构建每棵决策树,使用直方图来寻找分割点可以直接将原本的复杂度O(#data × #feature)直接降到O(#bin × #feature),因为bin的数量远小于原始数据的大小。具体来说,就是遍历所有的bin,分别计算将每个bin作为分割点时左子树和右子树的一阶梯度之和以及数据量。例如将第2个bin作为分隔点,那么左子树的 S L = H [ 0 ] . g + H [ 1 ] . g + H [ 2 ] . g S_L=H[0].g+H[1].g+H[2].g SL=H[0].g+H[1].g+H[2].g, n L = H [ 0 ] . n + H [ 1 ] . n + H [ 2 ] . n n_L=H[0].n+H[1].n+H[2].n nL=H[0].n+H[1].n+H[2].n,对应的右子树就是总的减去左子树的。由此得到分裂增益为 Δ l o s s = S L 2 n L + S R 2 n R − S P 2 n P \Delta loss={S_L^2\over n_L}+{S_R^2\over n_R}-{S_P^2\over n_P} Δloss=nLSL2+nRSR2−nPSP2(这个增益是不含正则化的,它的定义在原始论文的定义3.1有体现,但是有一些常数上的差别,总之都是通过分裂后的方差来度量的,关于LightGBM源码中实际应用的增益计算公式可以看这篇博客LightGBM源码如何计算增益)。最后选择分裂增益最大的那个bin作为最优分割点。这样,原本需要遍历所有样本点找分割点的,就变成了在所有bin之间找,速度加快了很多,内存消耗也变少。而且采用直方图离散化特征的值不仅不会丢失精度反而常常会起到正则化的效果,提高算法的泛化能力。
(这部分参考了论文Haijian Shi. Best-first decision tree learning. PhD thesis, The University of Waikato, 2007)。与Best-first(Leaf-wise)不同的是Depth-first(Level-wise)的生长方式,C4.5 (Quinlan,1993)、CART(Breiman et al.,1984)等都是使用Depth-first的方式,之前所说的XGBoost中的决策树也是使用这种方式,不过XGBoost框架也支持Leaf-wise。
这个算法用于自顶向下的决策树生长,在每个步骤中使用分而治之的策略以深度优先的顺序扩展每个节点。具体来说,首先选择一个属性放在根节点上,然后根据一些标准(例如信息增益或基尼指数)为这个属性创建一些分支。然后,将训练集拆分为各个子集,每个子集对应一个扩展的分支,子集的数量与分支的数量相同。然后,固定顺序(通常是从左到右)对子节点重复这个拆分操作。如果在任何时候,某个节点上的纯度达到阈值或者树的深度达到预先设定的标准,则停止拆分,将该节点变为终端节点。
对于boosting来说,这样生长策略使得计算机可以同时分裂同一层的叶子,从而进行多线程优化,而且比较容易控制模型的复杂度(深度),因此不容易过拟合;但不加区分的对待同一层的叶子,带来了很多没必要的开销,因为实际上很多叶子的分裂增益较低,没必要进行搜索和分裂。
这个算法不考虑决策树层级的顺序,而是每次从决策树当前的所有叶子节点中,找到分裂增益最大的一个叶子节点,然后分裂,如此循环往复。具体来说,当根节点创建分支后,将分支添加到一个候选的空节点列表中。然后,下一次的分裂就从这个列表中选取增益最大的分支进行,并将当前分裂结点从空节点列表中移除,将分裂后的子分支加入到列表中。如此循环进行下一次分裂。如果在进行分裂时会导致后续节点实例数少于预先设定的单个叶子中最小实例数量,则不分割当前节点,并将它移除出列表。最后如果分裂纯度都达到阈值则将当前列表中所有节点都变为终端节点,完成最佳优先(Best-first)树的构造过程。
与leve-wise的策略相比,在分裂次数相同的情况下,leaf-wise减少了对增益较低的叶子节点的分裂计算,可以降低误差,得到更好的精度。但它的缺点是可能会生成较深的决策树。因此,LightGBM在Leaf-wise上增加了限制最大深度的参数,在保证算法高效的同时防止过拟合。
为了加快训练过程,首先想到的就是减少样本量,也就是对数据子采样。之前的SGBT可以设置采样率从而加速GBDT的训练过程,但是相比较而言它会损失一定的精度。GOSS(Gradient-based One-Side Sampling)就是一种新的GBDT采样方法,它可以在减少数据数量和保持决策树的精度之间实现良好的平衡。
从GOSS的全程可以看出,它是基于梯度的一种采样方法。LightGBM认为如果一个样本的梯度取值很小,那么表示它已经被良好地训练,从而进步的空间也会比较小,因此包含的信息较少,我们可以在训练的时候多关注那些梯度较大的样本,他们对于增益也有更大的影响。但如果单纯只信任那些梯度大的样本,直接丢弃梯度小的样本则会导致数据分布变化,最终影响我们训练出来的模型。所以GOSS就是根据数据梯度的绝对值进行排序,选择前 a ∗ 100 % a*100\% a∗100%的样本(筛选阈值)作为集合A,然后从剩下的数据中随机抽取 b ∗ 100 % b*100\% b∗100%的样本作为集合B。之后在计算信息增益时只在 A ∪ B A\cup B A∪B上运算,但是为了保持数据分布不变(这里的意思是总体数据原本梯度和是多少,采样之后的数据梯度之和还是多少),通过乘以一个常数 ( 1 − a ) / b (1-a)/b (1−a)/b来补偿小梯度样本。
但是这样做了之后到底能不能保持精度呢?或者说理论上它是以什么样的速度来逼近真实的增益的呢?下面是GOSS算法的理论保证,都是一些计算学习的理论分析,如果对理论不感兴趣可以跳过下面的部分。只需要知道GOSS在数据量大时,逼近是相当准确的,此外,随机采样可以看做是GOSS算法的一种特殊情况,即当 a = 0 a=0 a=0的情况。
参考原论文中定义3.1对方差增益的定义,原本特征 j j j在分割点 d d d的方差增益定义为:
使用GOSS后在 A ∪ B A\cup B A∪B上划分数据,得到的增益定义为:
为了说明 V ~ ( d ) \tilde V(d) V~(d)与 V ( d ) V(d) V(d)之间的差是随着样本数量的增加而减小的,并且是以一定的速度减小,作者给出了定理3.2。这个定理说明了GOSS算法的渐进逼近率为 O ( 1 n l j ( d ) + 1 n r j ( d ) + 1 n ) O\big({1\over n_l^j(d)}+{1\over n_r^j(d)}+{1\over \sqrt{n}}\big) O(nlj(d)1+nrj(d)1+n1),因为 C a , b , D C_{a,b},D Ca,b,D这些都是常数,所以只要划分的稍微均衡点( n l j ( d ) ⩾ n 且 n r j ( d ) ⩾ n n_l^j(d)\geqslant \sqrt n\text{且}n_r^j(d)\geqslant \sqrt n nlj(d)⩾n且nrj(d)⩾n),就能保证它的收敛速度为 n \sqrt n n。
原论文的补充材料里面有这个定理的证明,我跟着它的思路自己推了一遍(全程省去特征 j j j的上标),但是有一个地方卡住了(下图中问号处),一直没推过去,如果有同学知道怎么推的可以教教我。
除了减少样本量,还可以减少特征的数量以实现算法的加速。现实中存在大量稀疏的特征,例如某些类别特征通过独热编码变成列很多0-1特征的,像这样的稀疏性表明很多特征是相互排斥的,即它们不总是同时取非零值,所以可以将多个特征捆绑为一个特征,这就是EFB(Exclusive Feature Bundling)的思想(或者设置一定的冲突率,也就是允许少量样本不是互斥的,可以同时取非零值)。这样一来,前面所述的直方图算法的复杂度就从O(#data × #feature)降为O(#data × #bundle),其中bundle就是经过捆绑后的特征数,通常bundle远小于feature,由此起到加快训练的目的。
原论文中先是基于图模型来提出了一个排序算法选出哪些特征需要进行捆绑,但由于基于图的算法在特征数量很大时效率不高,它的时间复杂度为O(#feature × #feature),所以这里就不讨论那个算法而直接说明LightGBM是怎么做的。LightGBM为了提高排序效率以此来快速选出哪些特征需要捆绑,它简单地依据非零值的数量进行排序,因为非零值的数量越多那么特征之间越有可能造成冲突。
LightGBM源码LightGBM/src/io/dataset.cpp第51行:计算非零值的数量
int GetConfilctCount(const std::vector<bool>& mark, const int* indices, int num_indices, int max_cnt) {
int ret = 0;
for (int i = 0; i < num_indices; ++i) {
if (mark[indices[i]]) {
++ret;
if (ret > max_cnt) {
return -1;
}
}
}
return ret;
}
上述代码中可以看到,当非零值的数量超过最大值之后则输出-1,没超过就输出这个特征的非零值数量。排序好之后EFB要做的就是依次检查有序列表中的每个特征,如果当前捆绑小于最大冲突数则将该特征分配现有捆绑,否则用该特征建立新的捆绑。在源码70行的FindGroups函数中节选了以下程序:
LightGBM源码LightGBM/src/io/dataset.cpp第111行:判断是合并捆绑还是新建捆绑
for (auto gid : search_groups) {
const int rest_max_cnt = max_error_cnt - group_conflict_cnt[gid];
const int cnt = is_filtered_feature ? 0 : GetConfilctCount(conflict_marks[gid], sample_indices[fidx], num_per_col[fidx], rest_max_cnt);
if (cnt >= 0 && cnt <= rest_max_cnt) {
data_size_t rest_non_zero_data = static_cast<data_size_t>(
static_cast<double>(cur_non_zero_cnt - cnt) * num_data / total_sample_cnt);
if (rest_non_zero_data < filter_cnt) { continue; }
need_new_group = false;
features_in_group[gid].push_back(fidx);
group_conflict_cnt[gid] += cnt;
group_non_zero_cnt[gid] += cur_non_zero_cnt - cnt;
if (!is_filtered_feature) {
MarkUsed(&conflict_marks[gid], sample_indices[fidx], num_per_col[fidx]);
}
if (is_use_gpu) {
group_num_bin[gid] += bin_mappers[fidx]->num_bin() + (bin_mappers[fidx]->GetDefaultBin() == 0 ? -1 : 0);
}
break;
}
}
对于特征的合并,由于LightGBM是基于直方图的算法来存储离散的bin数据,所以特征的合并是通过使互斥特征分别从属不同的bin来构造捆绑特征的。因为算法必须保证能从捆绑特征值中识别出原始特征的取值,所以这里EFB为了保留特征,将捆绑内不同的特征加上一个偏移量(特征的取值范围),使不同特征的值分别到不同bin内。
这里源码太难了现在的我还看不懂,放在这里说不定以后能看懂呢LightGBM源码LightGBM/include/LightGBM/feature_group.h现在分析下论文里的伪代码:
假设一个捆绑F中包含两个特征 f 1 , f 2 f_1,f_2 f1,f2,对于捆绑好的特征F也对应一个新的bin数据,上面的代码中就是newBin,它的第一个箱子newBin[1]首先设为0,然后对第一个特征 f 1 f_1 f1,如果 f 1 f_1 f1的第一个箱子不为零,那么就将 f 1 f_1 f1的第一个箱子的值加上 f 1 f_1 f1的取值范围赋给newBin[1];正常说来如果冲突率为0,那么第二个特征 f 1 f_1 f1的第一个箱子就应该是0,假如说允许一定的冲突率正好这个箱子不是0,那么就在当前的newBin[1]上再加上第二个特征 f 2 f_2 f2的第一个箱子的值和 f 2 f_2 f2的取值范围赋。然后开始循环捆绑好的特征F的第二个箱子newBin[2]…直到所有箱子都遍历。
这样,EFB算法就将许多稀疏特征(包括独热编码特征和隐式互斥特征)合并成相对少很多的特征,捆绑的过程中包含了基本的稀疏特征优化。这样的性能使得LightGBM还直接支持类别特征,因为一般类别特征都需要通过独热编码来生成许多0-1特征才能进行训练。而且,EFB在训练之前仅处理一次,不需要在树模型的学习过程中为每个特征记录非零数据表花费额外成本。更重要的是,由于许多先前独立的特征捆绑在一起,可以显著改善空间局限性并提高缓存命中率。因此EFB算法大大缩短了训练时间。
所有参考资料都以蓝色连接标出,可以直接在文中寻找对应资料。
最后感叹一句:LightGBM好难啃啊!!!