xgboost源码

src/tree/tree_updater.cc(树模型的具体实现)

 src/tree和src/linear分别是树和线性模型的具体实现,tree_updater是updater的入口,每一个Updater是对一棵树进行一次更新。其中的Updater分为两类:计算类和辅助类,updater都继承于TreeUpdater,互相之间又有调有关系,比如:prune调用sync,colmaker和fast_hist调用prune。

 以下为辅助类:

i. Src/tree/updater_prune.cc 用于剪枝

ii. Src/tree/updater_refresh.cc 用于更新权重和统计值

iii. Src/tree/updater_sync.cc 用于在分布式系统的节点间同步数据

iv. Src/tree/split_evaluator.cc 定义了两种切分方法:弹性网络elastic net, 单调约束monotonic,在此为切分评分,正则项在此发挥作用。打发的依据是差值,权重和正则化项。

 以下为算法类(基本都在xgboost论文第三章描述)
 对于树算法,最核心的是如何选择特征和特征的切分点,具体原理请见CART,算法,信息增益,熵等概念,这里实现的是几种树的生成方法。

v. Src/tree/updater_colmaker.cc 贪婪搜索算法(Exact Greedy Algorithm),最基本的树算法,一般都用它举例说明,这里提供了分布和非分布两种支持。在每个特征中选择该特征下的每个值作为其分裂点,计算增益损失。由内至外,关键函数分别是: EnumerateSplit() 穷举每一个枚举值,用split_evaluator打分。 ParallelFindSplit() 多线程,其它同上 UpdateSolution() 调上面两个split(),更新候选方案 FindSplit() 在当前层寻找最佳切分点,对比各个候选方案,方案来自上面的UpdateSolution()

vi. Src/tree/updater_histmaker.cc 它是xgboost默认的树生成算法, 它和后面提到的skmaker都继承自BaseMaker(BaseMaker的父类是TreeUpdate)是基于直方图选择特征切分点。 HistMaker提取Local和Global两种方式,Global是学习每棵树前, 提出候选切分点;Local是每次分裂前,重新提出候选切分点。 UpdateHistCol() 对每一个col,做直方图分箱,返回一个分界Entry列表。

vii. Src/tree/updater_skmaker.cc 继承自BaseMaker(BaseMaker父类TreeUpdate)加权分位数草图,用子集替代全集,使用近似的 sketch 方法寻找最佳分裂点。

=============================================================

============================================================================

=========================================================================================

XGBoost_源码初探

96

xieyan0811

2018.09.09 22:18 字数 2334 阅读 263评论 0喜欢 1

1. 说明

 本篇来读读Xgboost源码。其核心代码基本在src目录下,由C++实现,40几个cc文件,代码11000多行,虽然不算太多,但想把核心代码都读明白,也需要很长时间。 我觉得阅读的目的主要是:了解基本原理,流程,核心代码的位置,修改从哪儿入手,而得以快速入门。因此,需要跟踪代码执行过程,同时查看在某一步骤其内部环境的取值情况。具体方法是:单步调试或在代码中加入一些打印信息,因此选择了安装编译源码的方式。

2. 下载编译

 用参数--recursive可以下载它的支持包rabit和cur,否则编不过

$ git clone --recursive https://github.com/dmlc/xgboost
$ cd xgboost
$ make -j4

3. 运行

 测试程序demo目录中有多分类,二分类,回归等各种示例,这里从二分类入手。

$ cd demo 
#运行一个测试程序 
$ cd binary_classification
$ ./runexp.sh # 可以通过修改cfg文件,增加迭代次数等,进一步调试

4. 主流程

 下面从main()开始,看看程序执行的主要流程,下图是一个示意图,每个黄色框对应一个cc文件,可以将它视作调用关系图,并非完全按照类图绘制,同时省略了一些主流程以外的细节,请各位以领会精神为主。

1) Src/cli_main.cc:(主程序入口)

i. CLIRunTask():解析参数,提供三个主要功能:训练,打印模型,预测.

ii. CLITrain():训练部分,装载数据后,主要调用学习器Learner实际功能(配置cofigure,迭代,评估,存储……),其中的for循环包含迭代调用计算和评估。

2) Src/learner.cc:(学习器)

 定义三个核心句柄gbm_(子模型tree/linear),obj_(损失函数),metrics_(评价函数)

i. UpdateOneIter():此函数会在每次迭代时被调用,主要包含四个步骤:调整参数(LazyInitDMatrix()),用当前模型预测(PredictRaw(),gbm_-> PredictBatch()),求当前预测结果和实际值的差异的方向(obj_->GetGradient()),根据差异修改模型(gbm_->DoBoost()),后面逐一细化。

ii. EvalOneIter() 支持对多个评价数据集分别评价,对每个数据集,先进行预测(PredictRaw()),评价(obj_->EvalTransform()),再调metrics_中的各个评价器,输出结果。

3) Src/metric/metric.cc(评价函数入口)

 基本上,每个目录都有一个入口函数,metric.cc是评价函数的入口,learn允许同时支持多个评价函数(注意评价函数和误差函数不同)。主要三种评价函数:多分类,排序,元素评价,分别定义在三个文件之中。

4) Src/objective/objective.cc(损失函数入口)

 objective.cc是损失函数的入口,Learner::load()函数调用Create()创建误失函数,该目录中实现了:多分类,回归,排序的多种损失函数(每个对应一个文件),每个损函数最核心的功能是GetGradient(),另外也可以参考plugin中示例,自定义损失函数。 例如:src/objective/regression_obj.cc(最常用的损失函数RegLossObj())计算一阶导,二阶导,存入gpair结构。这里加入了样本的权重,scale_pos_weight也是在此处起作用。

5) src/gbm/gbm.cc(迭代器Gradient Booster)

 这里是对模型的封装,主要支持tree和linear两种方式,树分类器又包含GBTree和Dart两种,Dart主要加入了归一化和dropout防过拟合,详见参考部分。 gbm.cc中也有三个重要句柄:model_存储当前模型数据,updaters_管理每一次迭代的更新算法, predictor_用于预测

i. DoBoost()和BoostNewTrees() 进一步迭代生成新树,详建更新器部分

ii. Predict*() 调用各种预测,详见预测部分

6) src/predictor/predictor.cc(预测工具入口)

 predictor.cc也是一个入口,可调用cpu和gpu两种预测方式。

i. PredValue():核心函数,计算了从训练到当前迭代的所有回归树集合(以回归树为例)。

7) src/tree/tree_updater.cc(树模型的具体实现)

 src/tree和src/linear分别是树和线性模型的具体实现,tree_updater是updater的入口,每一个Updater是对一棵树进行一次更新。其中的Updater分为两类:计算类和辅助类,updater都继承于TreeUpdater,互相之间又有调有关系,比如:prune调用sync,colmaker和fast_hist调用prune。

 以下为辅助类:

i. Src/tree/updater_prune.cc 用于剪枝

ii. Src/tree/updater_refresh.cc 用于更新权重和统计值

iii. Src/tree/updater_sync.cc 用于在分布式系统的节点间同步数据

iv. Src/tree/split_evaluator.cc 定义了两种切分方法:弹性网络elastic net, 单调约束monotonic,在此为切分评分,正则项在此发挥作用。打发的依据是差值,权重和正则化项。

 以下为算法类(基本都在xgboost论文第三章描述)
 对于树算法,最核心的是如何选择特征和特征的切分点,具体原理请见CART,算法,信息增益,熵等概念,这里实现的是几种树的生成方法。

v. Src/tree/updater_colmaker.cc 贪婪搜索算法(Exact Greedy Algorithm),最基本的树算法,一般都用它举例说明,这里提供了分布和非分布两种支持。在每个特征中选择该特征下的每个值作为其分裂点,计算增益损失。由内至外,关键函数分别是: EnumerateSplit() 穷举每一个枚举值,用split_evaluator打分。 ParallelFindSplit() 多线程,其它同上 UpdateSolution() 调上面两个split(),更新候选方案 FindSplit() 在当前层寻找最佳切分点,对比各个候选方案,方案来自上面的UpdateSolution()

vi. Src/tree/updater_histmaker.cc 它是xgboost默认的树生成算法, 它和后面提到的skmaker都继承自BaseMaker(BaseMaker的父类是TreeUpdate)是基于直方图选择特征切分点。 HistMaker提取Local和Global两种方式,Global是学习每棵树前, 提出候选切分点;Local是每次分裂前,重新提出候选切分点。 UpdateHistCol() 对每一个col,做直方图分箱,返回一个分界Entry列表。

vii. Src/tree/updater_skmaker.cc 继承自BaseMaker(BaseMaker父类TreeUpdate)加权分位数草图,用子集替代全集,使用近似的 sketch 方法寻找最佳分裂点。

5. 其它

1) GPU,多线程,分布式

 代码中也有大量操作GPU,多线程,分布式的操作,这里主要介绍核心流程,就没有提及,详见代码,其中.cu和.cuh是主要针对GPU的程序。

2) 关键字说明

 CSR:csr_matrix一种存储格式
 Dmlc(Deep Machine Learning in Common):分布式深度机器学习开源项目
 Rabit:可容错的allrecude(分布式),支持python和C++,可以运行在包括MPI和Hadoop 等各种平台上面
 Objective与Metric(Eval):这里的Metric和Eval都指评价函数,Objective指损失函数,它们计算的都是实际值和预测值之间的差异,只是用途不同,Objective主要在生成树时使用,用于计算误差和通过误差的方向调整树;而评价函数主要用于判断模型对数据的拟合程度,有时通过它判断何时停止迭代。

3) 基于直方图的切分点选择

 分位数quantiles:即把概率分布划分为连续的区间,每个区间的概率相同。把数值进行排序,然后根据你采用的几分位数把数据分为几份即可。
 xgboost用二阶导h对分位数进行加权,让相邻两个候选分裂点相差不超过某个值ε。因此,总共会得到1/ε个切分点。
 通过特征的分布,按照加权直方图算法确定一组候选分裂点,通过遍历所有的候选分裂点来找到最佳分裂点。它不会枚举所有的特征值,而是对特征值进行聚合统计,然后形成若干个bucket(桶),只将bucket边界上的特征值作为split point的候选,从而获得性能提升,对稀疏数据效果好。

6. 参考

1) XGBoost Documentation

https://xgboost.readthedocs.io/en/latest/

2) xgboost入门与实战(原理篇)

https://blog.csdn.net/sb19931201/article/details/52557382

3) XGBoost解析系列--源码主流程

https://blog.csdn.net/matrix_zzl/article/details/78699605

4) XGBoost 论文翻译+个人注释

https://blog.csdn.net/qdbszsj/article/details/79615712

5) DART booster

https://blog.csdn.net/Yongchun_Zhu/article/details/78745529

6) 『我爱机器学习』集成学习(三)XGBoost

https://www.hrwhisper.me/machine-learning-xgboost/

 

 

===========================

=================================================

==========================================================================

XGBoost源码阅读笔记(2)--树构造之Exact Greedy Algorithm

2017年07月27日 20:47:37 flydreamforever 阅读数:2561

在上一篇《XGBoost源码阅读笔记(1)--代码逻辑结构》中向大家介绍了XGBoost源码的逻辑结构,同时也简单介绍了XGBoost的基本情况。本篇将继续向大家介绍XGBoost源码是如何构造一颗回归树,不过在分析源码之前,还是有必要先和大家一起推导下XGBoost的目标函数。本次推导过程公式截图主要摘抄于陈天奇的论文《XGBoost:A Scalable Tree Boosting System》。在后续的源码分析中,会省略一些与本篇无关的代码,如并行化,多线程。

一、目标函数优化

XGBoost和以往的GBT(Gradient Boost Tree)不同之一在于其将目标函数进行了二阶泰勒展开,在模型训练过程中使用了二阶导数加快其模型收敛速度。与此同时,为了防止模型过拟合其给目标函数加上了控制模型结构的惩罚项。

 

图1-1  目标函数

目标函数主要有两部分组成。第一部分是表示模型的预测误差;第二部分是表示模型结构。

当模型预测误差越大,树的叶子个数越多,树的权重越大,目标函数就越大。我们的优化目标是使目标函数尽可能的小,这样在降低预测误差的同时也会减少树叶子的个数以及降低叶子权重。这也正符合机器学习中的“奥卡姆剃刀”原则,即选择与经验观察一致的最简单假设。

图1-1的目标函数由于存在以函数为参数的模型惩罚项导致其不能使用传统的方式进行优化,所以将其改写成如下形式

图1-2 改变形式的目标函数

图1-2与图1-1的区别在于图1-1是通过整个模型去优化函数,而图1-2的优化目标是每次迭代过程中构造一个使目标函数达到最小值的弱分类器,从这个过程中就可以看出图1-2使用的是贪婪算法。将图1-2中的预测误差项在处进行二阶泰勒展开:

图1-3 二阶泰勒展开

并省去常数项

图1-4 省去常数项

图1-4就是每次迭代过程中简化的目标函数。我们的目标是在第t次迭代过程中获得一个使目标函数达到最小值的最优弱分类器,即。在这里累加项n是样本实例的个数,为了使编码更加方便,定义一个新的变量表示表示叶子j的所有样本实例

图1-5 新的变量

同时展开目标函数的模型惩罚项,并以叶子为纬度可以改写成

图1-6 以叶子为纬度的目标函数

这里函数f是将对应实例归类到对应的叶子下,并返回该实例在当前叶子下的权重w。图1-6对叶子权重w求导,便得出最优的叶子权重w

 

图1-7最优的叶子权重

与此同时将权重代入目标函数,并且省去常量,便得到了目标函数的解析式

 

图1-8 目标函数的解析式

我们的目标便是极小化该目标函数解析式。目标函数的解析式可以通过图1-9清晰形象的描绘出来

 

图1-9 目标函数的解析式计算过程

从图1-9可以清晰看出目标函数解析式的计算过程。目标函数的结果可以用来评价模型的好坏。这样在模型训练过程中,当前的叶子结点是否需要继续分裂主要就看分裂后的增益损失loss_change。

图1-10 分裂增益

增益损失loss_change的计算公式如图1-10所示,它是由该结点分裂后的左孩子增益加上右孩子增益减去该父结点的增益。这样在选择分裂点时候就是选择增益损失最大的分裂点。而寻找最佳分裂点是一个非常耗时的过程,上一篇《XGBoost源码阅读笔记(1)--代码逻辑结构》介绍了几种XGBoost使用的分裂算法,这里选择其中最简单的Exact Greedy Algorithm进行讲解:

图1-11  Exact Greedy Algorithm

图1-11算法的大意是遍历每个特征,在每个特征中选择该特征下的每个值作为其分裂点,计算增益损失。当遍历完所有特征之后,增益损失最大的特征值将作为其分裂点。由此可以看出这其实就是一种穷举算法,而整个树构造过程最耗时的过程就是寻找最优分裂点的过程。但是由于该算法简单易于理解,所以就以该算法来向大家介绍XGBoost源码树构造的实现过程。

如果对推导过程读起来比较吃力的话也没关系,这里主要需要记住的是每个结点增益和权值的计算公式。增益是用来决定当前结点是否需要继续分裂下去,而结点权值的线性组合即是模型最终的输出值。所以只要记住这两个公式就不会影响源码的阅读。

 

二、源码分析

1) 代码逻辑结构回顾

在上一篇结尾的时候说过源码最终调用过程如下:

 

 
  1. //gbtree.cc

  2. |--GBTree::DoBoost()

  3. |--GBTree::BoostNewTrees()

  4. |--GBTree::InitUpdater()

  5. |--TreeUpdater::Update()

 

这里简化后的源码如下:

 

 
  1. //gbtree.cc line:452

  2. BoostNewTrees(const std::vector<bst_gpair> &gpair,

  3. DMatrix *p_fmat,

  4. int bst_group,

  5. std::vector<std::unique_ptr<RegTree> >* ret) {

  6. this->InitUpdater();

  7. std::vector<RegTree*> new_tress;

  8. for(auto& up: updaters){

  9. up->Update(gpair,p_fmat, new_trees);

  10. }

 

gpair是一个vector向量,保存了对应样本实例的一阶导数和二阶导数。p_fmat是一个指针,指向对应样本实例的特征,new_trees用于存储构造好的回归树。

InitUpdater()是为了初始化updaters, 在上一篇文章也说过updaters是抽象类Class TreeUpdater的指针对象,定义了基本的Init和Update接口,该抽象的派生类定义了一系列树构造和剪枝方法。这里主要介绍其派生类Class ColMaker,该类使用的即使我们前面介绍的Exact Greedy Algorithm

 

2) Class ColMaker 数据结构介绍

在ClassColMaker 定义了一些数据结构用于辅助树的构造。

 

 
  1. //updater_colmaker.cc line:755

  2. const TrainParam& param; //训练参数,即我们设置的一些超参数

  3. std::vector<int> position; //当前样本实例在回归树结中对应结点的索引

  4. std::vector<NodeEntry> snode; //回归树中的结点

  5. std::vector<int> qexpand_; //保存将有可能分类的结点的索引

 

XGBoost的树构造类似于BFS(Breadth First Search),它是一层一层的构造树结点。所以需要一个队列qexpand_用来保存当前层的结点,这些结点会根据增益损失loss_change决定是否需要分裂形成下一层的结点。

 

3) Class ColMaker 树构造源码

 

 
  1. //updater_colmaker.cc line:29

  2. void ColMaker::Update(...)

  3. {

  4. for(size_t i = 0; i < trees.size(); ++){

  5. Builder builder(param);

  6. builder.Update(gpair, dmat, trees[i]);

  7. }

 

在Class ColMaker中定义了一个Class Builder类,所有的构造过程都由这个类完成。

 

 
  1. //updater_colmaker.cc line:89

  2. void ColMaker::Builder::Update(...)

  3. {

  4. this -> InitData(...); //初始化Builder参数

  5. // 初始化树根结点的权值和增益

  6. this -> InitNewNode(gpair, *p_fmat,*p_tree);

  7. for( int depth = 0; depth < param.max_depth; ++depth)

  8. {

  9. //给队列中的当层结点寻找分裂特征,构造出树的下一层

  10. this->FindSplit(depth, qexpand_, gpair, p_fmat, p_tree);

  11. //将当层各个非叶子结点中的样本实例分类到下一层的各个结点中

  12. this->ResetPosition();

  13. //更新队列,存储下一个层结点

  14. this->UpdateQueueExpand();

  15. //计算队列中下一层结点的权值和增益

  16. this->InitNewNode();

  17. //如果当前队列中没有候选分裂点,就退出循环

  18. If(qexpand_.size() == 0) break;

  19. }

  20. //由于树的深度限制,将队列中剩下结点都设置为树的叶子

  21. for(size_t i = 0; i < qexpand_.szie(); ++i)

  22. {

  23. ...

  24. }

  25. //记录构造好的回归树的一些辅助统计信息

  26. ...

  27. }

 

在以上代码中核心部分就是第一个循环里面的四个函数。我们首先来看下Builder::InitNewNode是如何初始化结点的增益和权值。

1. Builder::InitNewNode()

 

 
  1. //updater_colmaker.cc

  2. |--Builder::InitNewNode()

  3. |--for(size_t j = 0; j < qexpand_.size(); ++j)

  4. |--{

  5. |-- snode[qexpand[j]].root_gain = CalGain(...)

  6. |-- snode[qexpand[j]].weight = CalWeight(...)

  7. |--}

 

这里点的root_gain就是前面说的结点增益,将用于判断该点是否需要分裂。weigtht就是当前点的权值,最终模型输出就是叶子结点weight的线性组合。CalGain()和CalWeight()是两个模版函数,其简化的源码如下:

 

 
  1. //param.h line:242

  2. Template<typename TrainingParams, typename T>

  3. T CalGain(const TrainingParams &p, T sum_grad, T sum_hess)

  4. {

  5. return (sum_grad * sum_grad)/( sum_hess + p.reg_lambda);

  6. }

  7. //param.h line:275

  8. Template<typename TrainingParams, typename T>

  9. T CalWeight(const TrainingParams &p, T sum_grad, T sum_hess)

  10. {

  11. return -sum_grad /( sum_hess + p.reg_lambda);

  12. }

 

以上两个函数就是实现了我们一开始推导的两个公式,即计算结点的增益和权重。在初始化队列中的结点后,就需要对队列中的每个结点遍历寻找最优的分裂属性。

2. XGBoost::Builder::FindSplit()

 

 
  1. //updater_colmaker.cc

  2. |--XGBoost::Builder::FindSplit()

  3. |--//寻找特征的最佳分裂值

  4. |--for(size_t i = 0; i< feature_num; i++)

  5. |--{

  6. |-- XGBoost::Builder::UpdateSolution()

  7. | --XGBoost::Builder::EnumerateSplit()

  8. |--}

分裂过程最终调用了EnumerateSplit()函数,为了便于理解对代码变量名做了修改,其简化的代码如下

 
  1.  
  2. //updater_colmaker.cc line:508

  3. void EnumerateSplit(...){

  4. //建立个临时变量temp用来保存结点信息

  5. //空间大小为队列qexpand_中结点的最大索引

  6. vector<TStats> temp( std::max(qexpand_) + 1);

  7. TStats left_child(param) //结点分裂后左孩子的统计信息

  8. //遍历当前特征的所有值

  9. for(const ColBatch::Entry * it = begin; it != end; it += d_step){

  10. //得到当前特征值所对应的样本实例索引和特征值

  11. const int rIndex = it -> index;

  12. const int fValue = it->value;

  13. //根据当前样本索引得到其分类到的结点索引

  14. const int node_id = position[rIndex ];

  15. //结点分裂后右孩子的统计信息

  16. TStats & right_child = temp[node_id]

  17. //以当前特征值为分裂阈值,将当前样本归类到左孩子

  18. left_child = snode[node_id].stats - right_child;

  19. //计算增益损失

  20. int loss_change= CalcSplitGain(param, left_child, right_child)

  21. - snode[node_id].root_gain;

  22. //记录下最好的特征值分裂阈值,该阈值是左右孩子相邻特征值的中间值

  23. right_child.best.Update(loss_change, feature_id

  24. , 0.5 * (fValue + right_child.left_value) );

  25. //将当前样本实例归类到右孩子结点

  26. right_child.add(gpair, info , rIndex)

  27. }

从上述代码可以很清晰看出整个代码的流程思路就是之前介绍的Exact Greedy Alogrithm. 这里需要说明寻找分裂点有两个方向,一个是从左到右寻找,一个是从右到左寻找。上述代码只展示了一个方向的寻找过程。在寻找特征分裂阈值的时候分裂增益的计算函数是CalcSplitGain(),其具体代码如下:

 

 

 
  1. //param.h line:365

  2. double CalcSplitGain(const TrainParam ¶m

  3. , GradStats left, GradStats right) const {

  4. return left.CalcGain(param) + right.CalcGain(param);

  5. }

上述代码就是简单将左孩子和右孩子的增益相加,而增益损失loss_change就是将左右孩子相加的增益减去其父节点的增益。

 

3. XGboost::Builder::ResetPosition()

在寻找到当前层各个结点的分裂阈值之后,便可以在对应结点上构造其左右孩子来增加当前树的深度。当树的深度增加了,就需要将分类到当前层非叶子结点的样本实例分类到下一层的结点中。这个过程就是通过ResetPosition()函数完成的。

4. XGboost::Builder::UpdateQueueExpand()

XGboost::Builder::UpdateQueueExpand()函数更新qexpand_队列中的结点为下一层结点,然后在调用XGboost::Builder::InitNewNode()更新qexpand_中结点的权值和增益以便下一次循环

三、总结

本篇主要详细叙述了XGBoost使用Exact Greedy Algorithm构造树的方法,并分析了对应的源码。在分析源码过程中为了便于理解对代码做了一些简化,如省去了其中多线程,并行化的操作,并修改了一些变量名。在上述的树构造完成之后,还需要对树进行剪枝操作以防止模型过拟合。由于篇幅所限,这里就不再介绍剪枝操作。本篇文章只是起一个抛砖引玉的引导作用,想要对XGBoost实现细节有更加深刻理解,还需要去阅读XGBoost源码,毕竟有些东西用文字描述远不如用代码描述清晰。最后欢迎大家一起来讨论。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值