机器学习百试不爽之(三)xgboost&LightGBM

本文包含

  • xgboost在gbdt基础上的重要改进
  • xgboost的缺点
  • LightGBM在gbdt上的差异化实现
    1. 直方图算法 (从源码角度详细解读)
    2. 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ï¼ä¸å¤©åæ¶è·GitHub <wbr>1000 <wbr> <wbr>æ

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

https://zhuanlan.zhihu.com/p/25308051

https://blog.csdn.net/anshuai_aw1/article/details/83040541

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值