本文介绍XGBoost的源代码流程,先梳理源码主干流程,方便读者理解,结合函数名进行说明具体逻辑与功能。如果读者对底层实现感兴趣,后续会有细节具体专题,默认读者已读过《 XGBoost解析系列-准备》、《XGBoost解析系列-原理》。未来还会有分布式实现内容,这部分才是hard work。本文暂时先介绍单机版本实现,这样才更容易理解复杂的分布式版本。
重要说明:本文笔者基于xgboost git库的log commit:d9d5293cdbbbf67dc8ff9d4a3f171d0990fdd1ee (2017.10.26 17:31:10提交的commit),以demo文件夹binary_classification案例为例作为说明例子,对应的配置文件为:mushroom.conf,默认读者阅读过《 XGBoost解析系列-准备》,并将相应的配置修改正确,否则下面运行会出错。进入xgboost主目录,运行命令:
./xgboost demo/binary_classification/mushroom.conf
该语句会运行整个xgboost框架,程序入口函数为cli_main.cc
中main
函数,调用xgboost::CLIRunTask(argc, argv);
主过程很简单,依次执行:
1. 判断参数合法性,不合法直接退出;
2. 调用rabit::Init
初始化整个框架的分布特性,rabit是分布式通信库。
3. 将配置文件通过kv形式读入vector
变量,实现基于common::ConfigIterator
继承于ConfigStreamReader
,上层父类ConfigReaderBase
,实现核心接口Next()
函数,调用GetNextToken
解析每行的token
,分别解析出参数name
与value
。
4. 使用sscanf
读入命令行参数到cfg变量。
5. 将cfg
变量初始化CLIParam
对象,继承于参数类模板dmlc::Parameter
,后续会有专题介绍模板宏定义参数。
6. 根据task
类型执行核心流程:训练train、dump模型DumpModel、预测Predict。其中主目录下demo/binary_classification/runexp.sh
脚本有完整例子,默认为训练train。
7. 程序退出时调用rabit::Finalize
释放资源
xgboost最核心部分当属Train过程,训练进入核心函数CLITrain(const CLIParam& param)
,根据上层实例化param
进行训练控制。主要执行以下:
1. rabit::IsDistributed()
判断是否为分布式模式,若是,则打印相关log信息。
2. DMatrix::Load()
根据数据文件路径,支持本地路径与分布式的hadoop文件路径,生成统一URI
路径格式;解析底层数据格式,支持libsvm、libffm
。解析生成数据源对象,再加载到内存数据CSR格式对象,即DMatrix
对象,专题详细介绍查看此处。加载训练数据与预测数据到dtrain
、deval
(支持多份eval数据),并合并到cache_mats
,cache_mats
用于构建全局特征统计信息,合并是因为deval
中的数据可能超过dtrain
的边界,或者dtrain
不存在。接着将dtrain
加入deval
,因为训练集也需要评估。
3. 使用cache_mats
实例化learner
,基于C++工厂设计模式,使用Learner::Create()
静态方法实例化出具体实现类LearnerImpl
。Learner
继承于rabit::Serializable
,分布式下具备相互通信功能。
4. 检查当前版本号rabit::LoadCheckPoint();
若为0,为初始状态,判断param.model_in
是否存在dump模型路径,存在则使用dump模型来load()
初始化learner对象,通过Configure()
初始化模型。否则,通过Configure()
初始化learner,再调用InitModel()
初始化内部模型。两者都需要调用Configure()
,根据配置信息初始化成员,过程如下:
1)构建map
,设置objective、booster、updater、predictor
等配置项
2)调用InitAllowUnknown()
构建训练参数对象LearnerTrainParam
3)不存在模型会调用InitAllowUnknown()
构建模型参数对象LearnerModelParam mparam
,
4) 前者调用,后者不调用:
前者load
模型直接实例化boost框架组件实例与目标函数组件实例,通过指针gbm_、obj_调用初始化,而后者在InitModel()
才生成实例,因此会跳过。
InitModel()
采用懒惰方式初始化,LazyInitModel()
过程如下:
1) 遍历cache_mats
数据得到本地特征数最大值,调用rabit::Allreduce
会上报本地特征数最大值,通过Max
算子计算全局最大值,利用Allreduce
同步所有主机。
2) 同样基于C++工厂设计模式,使用类静态函数Create()实例化损失函数框架组件与boost框架组件实例,后续会有专题说明。本文mushroom.conf
配置中目标函数与boost框架为:
a) objective=binary:logistic
,ObjFunction
基类使用静态函数Create()
实例化损失函数框架组件对象RegLossObj
。LogisticClassification
提供:i)PredTransform
生成base_score
作为boost初始值;ii)一阶梯度计算FirstOrderGradient
与二阶梯度计算SecondOrderGradient
。ObjFunction
还实现抽象方法GetGradient()
调用具体类的梯度计算方法。
b)booster=gbtree
,GradientBooster
基类使用静态函数Create()
实例化boost框架组件对象GBTree()
,继承于抽象类GradientBooster
。i)调用Configure()
方法来初始化内部成员:构建GBTreeModel
核心成员model_
,该成员封装回归树集合vector
; ii)清空updaters列表,后续会构建;iii)初始化预测器predictor
:基于工厂设计模式,Predictor
基类调用静态函数Create()
,根据配置参数”cpu_predictor”生成对应CPUPredictor
对象。
5. 根据参数num_round
执行迭代:UpdateOneIter
执行单次迭代更新,EvalOneIter
每次迭代后对预测数据集进行预测。迭代结束前会判断是否保存模型,最后检查rabit 同步version号,rabit::CheckPoint
同步所有主机完成状态,开始下一轮的迭代。
下面把UpdateOneIter
与EvalOneIter
单独进行详解。
UpdateOneIter
流程主要有以下几个步骤:
1. LazyInitDMatrix(train);
2. PredictRaw(train, &preds_);
3. obj_->GetGradient(preds_, train->info(), iter, &gpair_);
4. gbm_->DoBoost(train, &gpair_, obj_.get());
LazyInitDMatrix
采用lazy
方式构建ColIter
列迭代器,加载数据进内存为CSR
格式存储,但是xgboost分裂点查找是基于特征内的实例数据,因此需要将CSR
格式存储转化为CSC
格式存储。HaveColAccess()
函数判断DMatrix
对象是否存在ColIter
成员,不存在则构建:
1. 根据树构建模式tree_method
,取值范围:'auto', 'approx', 'exact', 'hist', 'gpu_exact', 'gpu_hist'
等,默认设置为'auto'
。使用'auto'
自适应到具体的算法,对于数据量小于 222 使用'exact'
精确方法,否则会重置'approx'
近视方法。计算批次量大小max_row_perbatch=min(用户设置max_row_perbatch, safe_max_row)
,进行批次处理,其中safe_max_row=
216
2. 调用InitColAccess()
初始化构建ColIter
对象,考虑数据采样率prob_buffer_row
,使用伯努利采样。分2种情况讨论:
1)如果记录数num_row
小于max_row_perbatch
,则调用MakeOneBatch()
,col_iter_.cpages_
绑定SparsePage
对象,使用common::ParallelGroupBuilder
多线程并行加速CSR
格式转化为CSC
格式,先说下ParallelGroupBuilder
与SparsePage
定义如下:
template<typename ValueType, typename SizeType = size_t>
struct ParallelGroupBuilder {
std::vector &rptr; // CSC结构的offset引用,即下标为特征id,value为特征数据在data中的偏移
std::vector &data; // 真正数据存储的引用,真正数据块存储于SparsePage对象中
std::vector<std::vector > &thread_rptr; // 线程统计fid数
std::vector<std::vector > tmp_thread_rptr; // 临时对象
}
class SparsePage {
public:
bst_uint min_index;
std::vector offset; // 真正的CSC结构的offset数据
std::vector data; // 真正的CSC结构的data数据
}
ParallelGroupBuilder
利用CSR
存储数据生成CSC
格式存储数据,核心是设计上述数据结构,利用OMP设计多线程操作,线程之间操作互相独立不互斥,极大提升效率。提供核心的3个函数:AddBudget()、InitStorage()、Push()
,AddBudget()
使用多线程并行遍历CSR
存储数据,统计featureId
对应的记录数,这样就准确知道特征数据开始的偏移;InitStorage()
将统计信息汇总,更新后期线程能push操作的范围,这样后期多线程push操作不互斥,同时设置CSC
结构的offset
数据;push()
被多线程调用,将SparseBatch::Entry
对象根据当前线程获取到的offset
位置插入到data中。
最后MakeOneBatch
执行sort
排序,对每个featureId
对应的数据进行排序,完成论文中对每个特征,记录按照特征值进行预排序过程。ParallelGroupBuilder
实现是个很棒的想法,操作offset
节约内存,设计的数据结构使得多线程操作独立而不互斥,无缝连接OMP多线程算法效率真的很高,很值得工作中学习使用。具体操作如图:
2)否则记录数过得,会调用MakeManyBatch()
构造多个SparsePage
对象,不断收集训练数据,一旦记录数大于max_row_perbatch
,触发执行MakeColPage
操作,该过程与MakeOneBatch
类似,不再累述,差别在于MakeColPage
是针对部分数据,而不是全量。
InitColAccess
执行完生成col_iter_.cpages_
,统计每列特征实例数,生成col_size_
变量。col_iter_.cpages_
数据没有合并成一块,因为CSC
存储每列实例数小于 216 ,可以使用16bit类型存储,节约内存,这是论文提到的Block Compression
优化。但是需要借助额外的结构col_size_
进行全量数据访问,满足col_size_[j]+=pcol->offset[j+1]-pcol->offset[j]
,现在训练数据已准备好,也完成了每个特征下,样本记录按照特征值排序,能提供数据的CSC
按照列遍历的能力。
3. SingleColBlock()
判断为多个列块, 且没有任何updater
,即使设置精确分裂算法,程序也会重置成近似算法grow_histmaker+prune
,并且初始化boost框架组件。
PredictRaw
对预测数据进行预测,后续根据预测值才能计算一阶梯度与二阶梯度,间接调用gbm_->PredictBatch
,利用CSR
行遍历访问能力,批次预测实例数据。gbm_
框架配置预测器对象predictor
,要么是CPUPredictor
或者GPUPredictor
,默认配置为CPUPredictor
,这里以CPUPredictor
进行说明。
1. 首先predictor
调用PredictFromCache
,查看上次训练完是否对eval数据集进行评估,train数据集初始化时早已合并到eval数据集,如果找到就直接使用评估后的预测值,返回true。第一次调用时,由于还未训练,直接返回false,进行下面步骤。
2. 调用InitOutPredictions
函数,将预测值设置为base_margin
。如果用户在训练数据文件中对实例设置base_margin
,则使用设置值,否则使用参数设置model.base_margin
。
3. 调用PredLoopInternal
函数,以base_margin
为初始值,使用GBTreeModel
对象进行预测。实际上该对象封装了训练到当前迭代的所有回归树集合,额外判断是否为多分类任务,即num_output_group!=1
。使用不同参数,调用预测核心方法PredLoopSpecalize()
。该函数中通过K=8
设置预测的local batch
,利用OMP多线程对记录并行预测。每个线程内按照local batch
执行。为什么使用local batch
,具备缓存预取功能。其他地方也有使用这种技术。
PredictRaw
过程直接调用PredictBatch
, PredictBatch
是个通用接口。训练过程只有第一次执行调用PredictBatch
中的PredLoopInternal
,而且第一次调用GBTreeModel
对象内的回归树为空,不执行回归树的预测,第一次也就只有base_margin
值。纯粹的预测任务中,调用PredictBatch
,才会执行回归树的预测过程。
obj_
是目标函数框架组件对象,本文以RegLossObj
为例,该框架是回归损失框架RegLossObj
,包含损失函数为LogisticRegression
,框架执行过程如下:
for (omp_ulong i = 0; i < ndata; ++i) {
bst_float p = Loss::PredTransform(preds[i]);
bst_float w = info.GetWeight(i);
if (info.labels[i] == 1.0f) w *= param_.scale_pos_weight;
if (!Loss::CheckLabel(info.labels[i])) label_correct = false;
out_gpair->at(i) = bst_gpair(Loss::FirstOrderGradient(p, info.labels[i]) * w,
Loss::SecondOrderGradient(p, info.labels[i]) * w);
}
可见,框架会通过PredTransform
将预测值转化为概率值p
,支持代价敏感性学习,利用样本权重w
(用户设置样本权重)和scale_pos_weight
(相当于正例加倍采样逻辑),最终生成样本权重。计算一阶梯度与二阶梯度并赋值给变量out_gpair
,这个变量在后面很重要,请读者注意。框架不仅执行梯度计算,还会计算EvalMetric
,打印出metric监控。
DoBoost
流程:i)支持多分类,根据参数num_output_group
确定分类输出数。如果是多分类,会利用OMP
并行对每类调用BoostNewTrees()
构建相应的xgboost模型,预测时会将每个模型的得分输出,利用softmax
来计算各个模型的概率,选择最大概率值对应的分类。ii)支持boosted random forest
,根据参数num_parallel_tree
来构建每次迭代树的个数来组成森林,参数默认1,构建单颗树森林。下面介绍BoostNewTrees()
过程:
1. 构造森林前调用InitUpdater()
;初始化更新器列表,本文配置最终生成的是"grow_colmaker + prune"
,根据工厂方法生成精确节点分裂算法更新器对象ColMaker
与剪枝器对象TreePruner
,均继承于TreeUpdater
类,基类提供统一update
更新接口。
2. 遍历num_parallel_tree
次,通过InitModel
初始化生成回归树RegTree
对象,最终合并成森林vector
, RegTree
定义以及相关类定义的字段如下:
class RegTree: public TreeModel {
std::vector node_mean_values;
}
struct RTreeNodeStat {
bst_float loss_chg; // 当前节点分裂造成的loss变化
bst_float sum_hess; // 当前节点下数据的二阶梯度hessian总和
bst_float base_weight; // 当前节点作为叶子节点时的weight值
int leaf_child_cnt; // 当前节点下面的叶子节点总数
};
class Node {
union Info{
bst_float leaf_value; // 叶子节点值,叶子节点使用
TSplitCond split_cond;// 分裂条件,非叶子节点使用
};
int parent_; // parent_&((1U<<31)-1)为父节点index,最高位0、1为左、右节点的标记,
int cleft_, cright_; // 左、右子节点index,cleft_=-1时为叶子节点,cright_默认-1,但可以存其他信息,设置为0表示待分裂节点。
unsigned sindex_; // sindex_&((1U<<31)-1U)为分裂特征的index,最高位0、1为miss值的左、右分裂方向
Info info_; // 额外信息
};
template<typename TSplitCond, typename TNodeStat>
class TreeModel {
std::vector nodes; // 树节点vector
std::vector<int> deleted_nodes;
std::vector stats; // 节点对应的统计值
std::vector leaf_vector; // 叶子节点vector,存储额外信息
TreeParam param; // 树参数设置
}
3. Updaters对遍历森林,对每颗树进行更新:节点特征分裂建树与树剪枝
。
DoBoost中核心步骤是3,先说分裂。使用ColMaker
对象,具体流程在update接口实现上。为了支持boosted森林,对学习率learning_rate
均分,因为随机森林是将结果累加作为预测输出。对应森林中的每棵树构建算法更新器Builder对象builder
,Builder类定义在ColMaker内部,操作流程如下:
this->InitData(gpair, *p_fmat, *p_tree);
this->InitNewNode(qexpand_, gpair, *p_fmat, *p_tree);
for (int depth = 0; depth < param.max_depth; ++depth) {
this->FindSplit(depth, qexpand_, gpair, p_fmat, p_tree);
this->ResetPosition(qexpand_, p_fmat, *p_tree);
this->UpdateQueueExpand(*p_tree, &qexpand_);
this->InitNewNode(qexpand_, gpair, *p_fmat, *p_tree);
// if nothing left to be expand, break
if (qexpand_.size() == 0) break;
}
为了梳理上述的流程,后续会对上述过程进行详解,下面先给出核心类定义与算法执行器Builder的定义,为了排版做了相关修改精简,其中函数定义就不附上参数:
struct SplitEntry {
bst_float loss_chg; // 节点分裂的增益loss变化值
unsigned sindex; // 分裂特征的index
bst_float split_value; // 特征的分裂值
};
template<typename TStats, typename TConstraint>
class ColMaker: public TreeUpdater {
TrainParam param; // 训练参数
struct ThreadEntry {
TStats stats; // TStats类型统计值,比如GradStats
TStats stats_extra; // 额外TStats类型统计值
bst_float last_fvalue; // 最后扫描到的特征值
bst_float first_fvalue; // 首次扫描到的特征值
SplitEntry best; // 最优的分裂方案(分裂特征index,分裂值,增益loss变化)
};
struct NodeEntry {
TStats stats; // 节点的统计值
bst_float root_gain; // 节点没有分裂时的增益
bst_float weight; // 当前计算的最优weight
SplitEntry best; // 最优的分裂方案(分裂特征index,分裂值,增益loss变化)
};
// 算法执行器
struct Builder {
const TrainParam& param; // 训练参数
const int nthread; // 训练期间的OMP线程数
std::vector feat_index; // shuffle后feature id对应的inddex
std::vector<int> position; // 每个样本当前节点位置
std::vector< std::vector > stemp; // 并行计算,线程间互不影响。每个线程计算的节点分裂信息
std::vector snode; // 节点统计信息
std::vector<int> qexpand_; // 逐层分裂时,对应待分裂节点集合
std::vector constraints_; // 约束条件集合
virtual void Update(); // 核心接口
inline void InitData();
inline void InitNewNode(); // 初始化qexpand_待分裂节点的统计信息
inline void UpdateQueueExpand(); // 当每层qexpand_分裂介绍后调用,作为下一层分裂的开始
inline void ParallelFindSplit(); // 并行当前fid对应的分裂值,不支持嵌套包含OMP并行函数
inline void UpdateEnumeration(); // 更新枚举最优方案
inline void EnumerateSplitCacheOpt(); // 统计于UpdateEnumeration,使用local batch实现缓存预期优化
inline void EnumerateSplit(); // 对于特定特征,枚举出最优分裂值
virtual void UpdateSolution(); // 更新solution候选集
inline void FindSplit(); // 逐层分裂,找到expand节点的分裂方案
inline void ResetPosition(); // 每次分裂后更新样本所在的节点信息
virtual void SyncBestSolution(); // 每个节点同步下最优方案
virtual void SetNonDefaultPosition(); // 设置特征值非空实例position
inline int DecodePosition(); // 解码position
inline void SetEncodePosition(); // 编码position
};
流程详解如下:
1. InitData
初始化开始的数据和状态信息,xgboost支持多任务训练,根据参数param.num_roots
设置回归树root节点数param.num_roots
。注意:builder
只更新每棵树,多个root节点是被认为一颗树,只不过有多个root节点而已,为了便于理解,复杂的多任务多分类boosted森林图示如下:
InitData
流程如下:
position
,如果是单任务,则root节点直接为0;如果是多任务,对应root信息为taskid。
position
取反,最高位为1,则实例在将来分裂统计会被跳过。
param.subsample
作为训练初始数据,删除实例也是
position
取反。
colsample_bytree
参数,不同于随机森林列采样,后面会有真正的随机列采样过程,利用伯努利采样生成回归树训练初始特征候选集,构建生成
feat_index
,index为序号,value为特征id。
stemp
,每个空间预设256个
ThreadEntry
,预设256个统计节点空间
snode
,预设
256
个
qexpand_
元素,同时设置第一层分裂节点为0到
param.num_roots-1
。
stemp、snode
都是基于生成所有节点,
qexpand_
为当前待分裂节点,树深在7层以内不需要额外
vector
内存分配,超过7层会先引起
stemp、snode
重新分配。
2. InitNewNode
初始化分裂前初始值,后续分裂结束会被调用继续下轮的分裂查找。
1)初始化qexpand_
待分裂节点对应index
在stemp、snode
的梯度统计信息与constraints_
的预设信息。
2)利用OMP多线程按行分片,并行统计训练数据所在节点position[ridx]
对应的统计信息,position<0
表示删除节点或者不进行继续分裂的节点,存在后者是因为xgboost是按照level逐层进行分裂查找,每层的数据是全量数据,按照position
来分配到expand_
分裂节点id上,对于早期某层节点无法继续分裂情况,会对该节点的所有的实例设置position<0
,因此需要对这部分实例进行过滤处理。
3)合并多线程梯度统计信息到snode
;设置约束条件,Colmaker
基于NoConstraint
,实际上不会执行任何约束设置操作;按照论文公式计算每个qexpand_
节点增益与最优值weight
值。
3. 根据参数param.max_depth
逐层分裂生成节点和查找分裂最优方案。首先调用FindSplit
:
1)每次开始查找前,为了支持随机森林的列采样特性,利用伯努利实现采样feat_index
,生成逐层分裂前的特征子集feat_set
。
2)使用ColIterator
遍历器基于feat_set
选择出每次分裂的特征子集,进行按列遍历,每次调用生成列batch
数据,调用UpdateSolution
找出batch
内的最优特征与特征分裂值。该过程非常复杂但有非常重要;首先调整并行模式,对于batch.size * 2 < nthread
,直接执行ParallelFindSplit
,否则,根据parallel_option
并行模式,选择执行EnumerateSplit
还是ParallelFindSplit
,无论哪种,对于特征存在缺失值情况,会有2次遍历:前向遍历+后向遍历查找,具体算法流程对应论文中的xgboost稀疏算法的前、后遍历流程。分裂枚举有2种并行模式:
a) EnumerateSplit
基于特征粒度的OMP多线程并行,特征上并行,但依次串行处理qexpand
节点,在线程分配到的特定特征子集内,找出对应的最优特征和特征分裂值。EnumerateSplit
内部首先判断是否使用cacheline aware
优化技术。默认是使用,直接调用EnumerateSplitCacheOpt
,结束返回,否则执行后续逻辑,与EnumerateSplitCacheOpt
是一致的。后者主要使用local cache buffer
做缓存预取优化效率,正常逻辑下CSR
遍历出特征排序后的实例index是乱序的,访问position
与gpair
不容易构建缓存,因此每次执行特征枚举之前,构建一次cache buffer,预取batch=32
的position
与gpair
到连续内存vector,后续枚举计算容易读取缓存, 便于理解,笔者给出EnumerateSplit
过程图示。
ParallelFindSplit
实现单个特征内数据并行+expand粒度并行,i)预先将单个特征内实例数据进行分片,每个线程在各种划分到的数据分片内,遍历数据,根据
position
计算对应expand的一阶导数和二阶导数统计值,记录边缘值
first_fvalue、last_fvalue
。ii)expand粒度并行,计算
(上一片last_fvalue + 当前first_fvalue)/2
作为分裂值、计算分裂值对应的增益作为初始值,以及对应分片数据上其实统计累计值。iii)利用计算好的分片起始统计值,数据分片并行,每个线程在当前数据分片下,更新expand节点的分裂最优值方案,最后会使用SyncBestSolution汇总计算出最终的最优值方案, 便于理解,笔者给出
ParallelFindSplit
过程图示。
qexpand
节点内每个节点的最优特征与特征分裂值,最优方案是由OMP多线程并行统计,存储在
stemp
变量中。按照
qexpand
节点
nid
案例遍历下所有线程对应的下
stemp[tid][nid]
,调用
NodeEntry
方法
best
即可。
qexpand
每个节点,最优增益变化大于阈值
rt_eps
,执行树的分裂,分裂成左、右叶子节点,并设置叶子节点
cright_
字段为0,表示该叶子节点是待分裂节点,重置父节点的
cleft_、cright_
,这样父节点本来属于叶子节点变成非叶子节点。否则不分裂,设置叶子节点的
weight
值和
cright_=-1
,此时该叶子节点不能够再被分裂,该叶子节点上的所有实例后期会设置
position<0
。
4. ResetPosition
更新实例的position
,即将实例划分到待分裂节点qexpand
的候选集上,主要流程如下:
1)首先对特征值非空实例进行节点划分,通过遍历qexpand
非叶子节点,生成分裂特征集合fsplits
,利用ColIterator
获取fsplits
的分片数据,进行batch
遍历。每个实例通过DecodePosition()
获取对应父节点,根据父节点分裂条件来进入不同子节点,即通过SetEncodePosition()
设置实例position()
对应的左、右节点上。
2)对特征值为空的数据进行节点划分,由于实例还挂在qexpand
节点上,对应节点仍然属于非叶子节点时,会根据默认分裂方向来划分。实际上对于InitData
初始删除的实例也会执行该步骤,只不过这部分实例不会被InitNewNode
统计,个人觉得没必要对已删除实例进行额外的划分工作。
3)之前已分配到叶子节点,而叶子节点原本属于待更新状态变成不更新状态是,会对处于该叶子节点上的实例position
取反,在将来分裂会被跳过。
5. UpdateQueueExpand
比较简单,对于qexpand
上非叶子节点扩展出左、右节点到newnodes
,最后qexpand = newnodes;
,个人觉得swap效率更高,没用中间多余的拷贝、释放。所以这个过程类似广度遍历,即xgboost是按照逐层训练的。
6. 继续调用InitNewNode
,跟上述2)一致,初始化信息与状态,主要是为了下层训练的开始。
7. 对于达到训练深度但是还没结束时,将多余的expand
节点都设置为不更新叶子节点状态,并设置最优weight
值。并将所有节点在builder
对象中上统计数据同步到RegTree
对象上。
剪枝过程相对来说要简单点,剪枝器只有TreePruner
类,继承于TreeUpdater
,由于每次迭代是森林,同样地,计算每颗树的学习率:lr = lr / trees.size();
,每棵树进行DoPrune()
操作:对所有叶子节点调用TryPruneLeaf()
,找到父节点,增益满足loss_chg < min_split_loss
,实现剪枝:将左、右子节点删除,设置父节点为叶子节点,同时基于当前节点回溯调用TryPruneLeaf继续剪枝。如果增益不满足则TryPruneLeaf()
直接返回。
剪枝器由于是在最后阶段调用,还包含同步器TreeSyncher
对象,也继承于TreeUpdater
。如果是单机模式直接返回,否则属于分布式模式,会将所有的回归森林每个RegTree
对象进行save
序列化生成string
,通过rabit::Broadcast
广播到分布式中其他节点上,其他节点通过load
反序列化生成得到森林,至此,完成实现分布式的迭代训练。
EvalOneIter流程包含打印metric与预测,首选通过目标函数组件得到相应metric配置,基于工厂模式,通过基类Metric
调用静态create()
实例化出metric对象。通过PredictRaw
进行预测,最后通过metric计算打印输出。PredictRaw
内部就不仔细将了,本文前面内容已经提到过。至此,一次迭代结束。