本文包含
- xgboost在gbdt基础上的重要改进
- xgboost的缺点
- LightGBM在gbdt上的差异化实现
- 直方图算法 (从源码角度详细解读)
- leaf-wise
本文不包含
- 各种框架优化的原理论证
xgboost 和 LightGBM是gbdt的两种实现框架。在项目实践和面基中都是经常碰到的。
gbdt是boosting方式训练的base model,而xgboost在gbdt基础做出众多改进,使得它成为业界的一大杀器。前人已经做了整理,这里直接引用(由于未能找到原始出处,这里没能指出具体引用源,若有侵权,请邮件联系)
1.传统GBDT以CART作为基分类器,xgboost还支持线性分类器,这个时候xgboost相当于带L1和L2正则化项的逻辑斯蒂回归(分类问题)或者线性回归(回归问题)。
2.传统GBDT在优化时只用到一阶导数信息,xgboost则对代价函数进行了二阶泰勒展开,同时用到了一阶和二阶导数。顺便提一下,xgboost工具支持自定义代价函数,只要函数可一阶和二阶求导。
3.Xgboost在代价函数里加入了正则项,用于控制模型的复杂度。正则项里包含了树的叶子节点个数、每个叶子节点上输出的score的L2模的平方和。从Bias-variance tradeoff角度来讲,正则项降低了模型的variance,使学习出来的模型更加简单,防止过拟合,这也是xgboost优于传统GBDT的一个特性。
4.Shrinkage(缩减),相当于学习速率(xgboost中的eta)。xgboost在进行完一次迭代后,会将叶子节点的权重乘上该系数,主要是为了削弱每棵树的影响,让后面有更大的学习空间。实际应用中,一般把eta设置得小一点,然后迭代次数设置得大一点。(补充:传统GBDT的实现也有学习速率)
5.列抽样(column subsampling)。xgboost借鉴了随机森林的做法,支持列抽样,不仅能降低过拟合,还能减少计算,这也是xgboost异于传统gbdt的一个特性。
6.缺失值的处理。对于特征的值有缺失的样本,xgboost可以自动学习出它的分裂方向。
7.xgboost工具支持并行。boosting不是一种串行的结构吗?怎么并行的?注意xgboost的并行不是tree粒度的并行,xgboost也是一次迭代完才能进行下一次迭代的(第t次迭代的代价函数里包含了前面t-1次迭代的预测值)。xgboost的并行是在特征粒度上的。我们知道,决策树的学习最耗时的一个步骤就是对特征的值进行排序(因为要确定最佳分割点),xgboost在训练之前,预先对数据进行了排序,然后保存为block结构,后面的迭代中重复地使用这个结构,大大减小计算量。这个block结构也使得并行成为了可能,在进行节点的分裂时,需要计算每个特征的增益,最终选增益最大的那个特征去做分裂,那么各个特征的增益计算就可以开多线程进行。
8.可并行的近似直方图算法。树节点在进行分裂时,我们需要计算每个特征的每个分割点对应的增益,即用贪心法枚举所有可能的分割点。当数据无法一次载入内存或者在分布式情况下,贪心算法效率就会变得很低,所以xgboost还提出了一种可并行的近似直方图算法,用于高效地生成候选的分割点。
xgboost做出的改进也并不是完美的。例如上面的第七点,关于并行计算的改进。由于pre sort的方式,不仅要保存原始的特征值,而且还需要保存排序的结果,造成了两倍的内存开支。另外,level-wise的叶子分裂方式,对同一层的所有节点,无区别的进行分裂,造成了极大的计算消耗。
LightGBM是另一种boosting方式的实现框架,在Higgs数据集上表现强劲。模型训练时间花费是xgboost的1/10,内存是xgboost的1/6, 同时模型的准确率还得到了提升。更多对比结果详见参考文献
LightGBM在boosting上做出的改进,主要也是针对了xgboos的显著问题,进行内存优化和时间开销优化。与一般的分析lightgbm和xgboost的方式不同,本文着眼于lightgbm的源代码,希望可以给出更为透彻的解析。
直方图算法
直方图算法的基本思想是先把连续的浮点特征值离散化成k个整数,同时构造一个宽度为k的直方图。在遍历数据的时候,根据离散化后的值作为索引在直方图中累积统计量,当遍历一次数据后,直方图累积了需要的统计量,然后根据直方图的离散值,遍历寻找最优的分割点。
很明显,在对特征进行离散化之后,对当前节点进行分割的时间复杂度从O(#data*#feature)优化到O(k*#features)。
针对lightgbm如何进行bins,下面进行解析。首先我将给出读了源码后,总结的实现流程。后面大家可以顺着源码,自己走一遍,把流程顺一下。
一句话小结:每个特征根据无重复特征值的数量是否大于bins限制,分为两种情况。如果<max_bins,直接无重复特征排序后,取两两特征值的平均作为划分点;如果>max_bins,当前特征值计数超过mean_bin_size(#data/max_bins)的单独成桶,其他的在桶内数量超过mean_bin_size或者下一个特征值计数计数超过mean_bin_size,分为一个桶。
伪代码分析
"""
下面所有变量针对一个具体特征而言
max_bin 预设的最大桶数
counts特征取值计数的数组
distinct_values为特征的不同的取值的数组
num_distinct_values; 无重复特征value的数量。
rest_bin_cnt 剩下待划分的桶数
rest_sample_cnt 剩下待划分的样本数
mean_bin_size = rest_sample_cnt / rest_bin_cnt表示桶内样本数量的期望值。
cur_cnt_inbin 当前桶内样本数量
"""
if num_distince_values <= max_bin:
直接无重复特征排序后,取两两特征值的平均作为划分点;
else:
初始化rest_sample_cnt, rest_bin_cnt, mean_bin_size,cur_cnt_inbin
for i in range(num_distince_values):
更新rest_sample_cnt, rest_bin_cnt, mean_bin_size,cur_cnt_inbin
if 当前特征值计数超过mean_bin_size(#data/max_bins)的单独成桶 || 桶内累积样本数量超过mean_bin_size || 下一个特征值计数计数超过mean_bin_size:
upper_bounds.append(当前特征值)#上边界
lower_bounds.append(下一个非重复特征值)#下边界
for b in bins:
bin_upper_bound.append((上边界+下边界)/ 2)
return bin_upper_bound
源码路径https://github.com/microsoft/LightGBM/blob/master/src/io/bin.cpp
std::vector<double> GreedyFindBin(const double* distinct_values, const int* counts,
int num_distinct_values, int max_bin, size_t total_cnt, int min_data_in_bin) {
std::vector<double> bin_upper_bound;
CHECK(max_bin > 0);
if (num_distinct_values <= max_bin) {
bin_upper_bound.clear();
int cur_cnt_inbin = 0;
for (int i = 0; i < num_distinct_values - 1; ++i) {
cur_cnt_inbin += counts[i];
if (cur_cnt_inbin >= min_data_in_bin) {
auto val = Common::GetDoubleUpperBound((distinct_values[i] + distinct_values[i + 1]) / 2.0);
if (bin_upper_bound.empty() || !Common::CheckDoubleEqualOrdered(bin_upper_bound.back(), val)) {
bin_upper_bound.push_back(val);
cur_cnt_inbin = 0;
}
}
}
cur_cnt_inbin += counts[num_distinct_values - 1];
bin_upper_bound.push_back(std::numeric_limits<double>::infinity());
} else {
if (min_data_in_bin > 0) {
max_bin = std::min(max_bin, static_cast<int>(total_cnt / min_data_in_bin));
max_bin = std::max(max_bin, 1);
}
double mean_bin_size = static_cast<double>(total_cnt) / max_bin;
// mean size for one bin
int rest_bin_cnt = max_bin;
int rest_sample_cnt = static_cast<int>(total_cnt);
std::vector<bool> is_big_count_value(num_distinct_values, false);
for (int i = 0; i < num_distinct_values; ++i) {
if (counts[i] >= mean_bin_size) {
is_big_count_value[i] = true;
--rest_bin_cnt;
rest_sample_cnt -= counts[i];
}
}
mean_bin_size = static_cast<double>(rest_sample_cnt) / rest_bin_cnt;
std::vector<double> upper_bounds(max_bin, std::numeric_limits<double>::infinity());
std::vector<double> lower_bounds(max_bin, std::numeric_limits<double>::infinity());
int bin_cnt = 0;
lower_bounds[bin_cnt] = distinct_values[0];
int cur_cnt_inbin = 0;
for (int i = 0; i < num_distinct_values - 1; ++i) {
if (!is_big_count_value[i]) {
rest_sample_cnt -= counts[i];
}
cur_cnt_inbin += counts[i];
// need a new bin
if (is_big_count_value[i] || cur_cnt_inbin >= mean_bin_size ||
(is_big_count_value[i + 1] && cur_cnt_inbin >= std::max(1.0, mean_bin_size * 0.5f))) {
upper_bounds[bin_cnt] = distinct_values[i];
++bin_cnt;
lower_bounds[bin_cnt] = distinct_values[i + 1];
if (bin_cnt >= max_bin - 1) { break; }
cur_cnt_inbin = 0;
if (!is_big_count_value[i]) {
--rest_bin_cnt;
mean_bin_size = rest_sample_cnt / static_cast<double>(rest_bin_cnt);
}
}
}
++bin_cnt;
// update bin upper bound
bin_upper_bound.clear();
for (int i = 0; i < bin_cnt - 1; ++i) {
auto val = Common::GetDoubleUpperBound((upper_bounds[i] + lower_bounds[i + 1]) / 2.0);
if (bin_upper_bound.empty() || !Common::CheckDoubleEqualOrdered(bin_upper_bound.back(), val)) {
bin_upper_bound.push_back(val);
}
}
// last bin upper bound
bin_upper_bound.push_back(std::numeric_limits<double>::infinity());
}
return bin_upper_bound;
}
leaf-wise的叶子分裂方式
抛弃了大多数GBDT工具使用的按层生长 (level-wise)的决策树生长策略,而使用了带有深度限制的按叶子生长 (leaf-wise)算法。Level-wise过一次数据可以同时分裂同一层的叶子,容易进行多线程优化,也好控制模型复杂度,不容易过拟合。但实际上Level-wise是一种低效的算法,因为它不加区分的对待同一层的叶子,带来了很多没必要的开销,因为实际上很多叶子的分裂增益较低,没必要进行搜索和分裂。
这个在实现上比较容易理解,不展开讨论。至此,我们已经将lightgbm在框架设计上最重要的两个优化点(直方图算法、leaf-wise叶子节点分裂)理解清楚。其他的优化点诸如:直方图做差加速,类别特征支持等等。感兴趣可以从参考文献入手进行拓展。
参考
https://www.msra.cn/zh-cn/news/features/lightgbm-20170105