GBDT源码分析之三:GBDT

0x00 前言

本文是《GBDT源码分析》系列的第三篇,主要关注和GBDT本身以及Ensemble算法在scikit-learn中的实现。

0x01 整体说明

scikit-learn的ensemble模块里包含许多各式各样的集成模型,所有源码均在sklearn/ensemble文件夹里,代码的文件结构可以参考该系列的第一篇文章。其中 GradientBoostingRegressorGradientBoostingClassifier分别是基于grandient_boosting的回归器和分类器。

0x02 源码结构分析

BaseEnsemble

ensemble中的所有模型均基于基类BaseEnsemble,该基类在sklearn/ensemble/base.py里。BaseEnsemble继承了两个父类,分别是BaseEstimator和MetaEstimatorMixin。BaseEnsemble里有如下几个方法,基本都是私有方法:

  • __init__: 初始化方法,共三个参数base_estimator, n_estimators, estimator_params。
  • _validate_estimator: 对n_estimators和base_estimator做检查,其中base_enstimator指集成模型的基模型。在GBDT中,base_estimator(元算法/基模型)是决策树。
  • _make_estimator: 从base_estimator中复制参数。
  • __len__: 返回ensemble中estimator的个数。
  • __getitem__: 返回ensemble中第i个estimator。
  • __iter__: ensemble中所有estimator的迭代器。

gradient_boosting.py

GradientBoostingClassifier和GradientBoostingRegressor这两个模型的实现均在gradient_boosting.py里。事实上,该脚本主要实现了一个集成回归树Gradient Boosted Regression Tree,而分类器和回归器都是基于该集成回归树的。

注意: 这里要先说明一个问题,GBDT本质上比较适合回归和二分类问题,而并不特别适用于多分类问题。在scikit-learn中,处理具有K类的多分类问题的GBDT算法实际上在每一次迭代里都构建了K个决策树,分别对应于这K个类别。我们在理论学习时并没有怎么接触过这个点,这里也并不对多分类问题做阐述。

实现Gradient Boosted Regression Tree的类是BaseGradientBoosting,它是GradientBoostingClassifier和GradientBoostingRegressor的基类。它实现了一个通用的fit方法,而回归问题和分类问题的区别主要在于损失函数LossFunction上。

下面我们梳理一下gradient_boosting.py的内容。

基本的estimator类

一些简单基本的estimator类,主要用于LossFunction中init_estimator的计算,即初始预测值的计算。举例来说:QuantileEstimator(alpha=0.5) 代表用中位数作为模型最初的预测值。

  • QuantileEstimator: 预测训练集target的alpha-百分位的estimator。
  • MeanEstimator: 预测训练集target的平均值的estimator。
  • LogOddsEstimator: 预测训练集target的对数几率的estimator(适合二分类问题)。
  • ScaledLogOddsEstimator: 缩放后的对数几率(适用于指数损失函数)。
  • PriorProbabilityEstimator: 预测训练集中每个类别的概率。
  • ZeroEstimator: 预测结果都是0的estimator。

LossFunction

LossFunction: 损失函数的基类,有以下一些主要方法

  • __init__: 输入为n_classes。当问题是回归或二分类问题时,n_classes为1;K类多分类为题时n_classes为K。
  • init_estimator: LossFunction的初始estimator,对应上述那些基本的estimator类,用来计算模型初始的预测值。在基类中不实现,并抛出NotImplementedError。
  • negative_gradient: 根据预测值和目标值计算负梯度。
  • update_terminal_regions: 更新树的叶子节点,更新模型的当前预测值。
  • _update_terminal_regions: 更新树的叶子节点的方法模板。

RegressionLossFunction

RegressionLossFunction: 继承LossFunction类,是所有回归损失函数的基类。

  • LeastSquaresError: init_estimator是MeanEstimator;负梯度是目标值y和预测值pred的差;唯一一个在update_terminal_regions中不需要更新叶子节点value的LossFunction。
  • LeastAbsoluteError: init_estimator是QuantileEstimator(alpha=0.5);负梯度是目标值y和预测值pred的差的符号,适用于稳健回归。
  • HuberLossFunction: 一种适用于稳健回归Robust Regression的损失函数,init_estimator是QuantileEstimator(alpha=0.5)。
  • QuantileLossFunction: 分位数回归的损失函数,分位数回归允许估计目标值条件分布的百分位值。

ClassificationLossFunction

ClassificationLossFunction: 继承LossFunction,是所有分类损失函数的基类。有_score_to_proba(将分数转化为概率的方法模板)以及_score_to_decision(将分数转化为决定的方法模板)两个抽象方法。

  • BinomialDeviance: 二分类问题的损失函数,init_estimator是LogOddsEstimator。
  • MultinomialDeviance: 多分类问题的损失函数,init_estimator是PriorProbabilityEstimator。
  • ExponentialLoss: 二分类问题的指数损失,等同于AdaBoost的损失。init_estimator是ScaledLogOddsEstimator。

其它

gradient_boosting.py文件里还有以下几个内容,再下一章里我们将对这些内容做深入分析。

  • VerboseReporter: 输出设置。
  • BaseGradientBoosting: Gradient Boosted Regression Tree的实现基类。
  • GradientBoostingClassifier: Gradient Boosting分类器。
  • GradientBoostingRegressor: Gradient Boosting回归器。

0x03 GDBT主要源码分析

BaseGradientBoosting

下面我们具体看一下BaseGradientBoosting这个基类,该类有几个主要方法:

  • __init__: 除了来自决策树的参数外,还有n_estimators, learning_rate, loss, init, alpha, verbose, warm_start几个新的参数。其中loss是损失函数LossFunction的选择,learning_rate为学习率,n_estimators是boosting的次数(迭代次数或者Stage的个数)。通常learning_rate和n_estimators中需要做一个trade_off上的选择。init参数指的是BaseEstimator,即默认为每个LossFunction里面的init_estimator,用来计算初始预测值。warm_start决定是否重用之前的结果并加入更多estimators,或者直接抹除之前的结果。
  • _check_params: 检查参数是否合法,以及初始化模型参数(包括所用的loss等)。
  • _init_state: 初始化init_estimator以及model中的状态(包括init_estimator、estimators_、train_score_、oob_improvement_,后三个都是数组,分别存储每一个Stage对应的estimator、训练集得分和outofbag评估的进步)。
  • _clear_state: 清除model的状态。
  • _resize_state: 调整estimators的数量。
  • fit: 训练gradient boosting模型,会调用_fit_stages方法。
  • _fit_stages: 迭代boosting训练的过程,会调用_fit_stage方法。
  • _fit_stage: 单次stage训练过程。
  • _decision_function:略。
  • _staged_decision_function:略。
  • _apply:返回样本在每个estimator落入的叶子节点编号。

fit方法

# 如果不是warm_start,清除之前的model状态
if not self.warm_start:
____self._clear_state()

# ......检查输入、参数是否合法
# ......如果模型没有被初始化,则初始化模型,训练出初始模型以及预测值;
# ......如果模型已被初始化,判断n_estimators的大小并重新设置模型状态。
# boosting训练过程,调用_fit_stages方法
n_stages = self._fit_stages(X, y, y_pred, sample_weight, random_state, begin_at_stage, monitor, X_idx_sorted)
# ......当boosting训练次数与初始化的estimators_长度不一致时,修正相关变量/状态,包括estimators_、train_score_、oob_improvement_
# fit方法返回self

_fit_stages方法

# 获取样本数(为什么每次都要获取样本数而不作为self.n_samples呢)
n_samples = X.shape[0]
# 判断是否做oob(交叉验证),仅当有抽样时才做oob
do_oob = self.subsample < 1.0
# 初始化sample_mask,即标注某一轮迭代每个样本是否要被抽样的数组
sample_mask = np.ones((n_samples, ), dtype=np.bool)
# 计算inbag(用来训练的样本个数)
n_inbag = max(1, int(self.subsample * n_samples))
# 获取loss对象
loss_ = self.loss_
# ......设置min_weight_leaf、verbose、sparsity等相关参数
# 开始boosting迭代
# begin_at_stage是迭代初始次数,一般来说是0,如果是warm_start则是之前模型结束的地方
i = begin_at_stage
# 开始迭代
for i in range(begin_at_stage, self.n_estimators):
    # 如果subsample < 1,do_oob为真,做下采样
    if do_oob:
        # _random_sample_mask是在_gradient_boosting.pyx里用cpython实现的一个方法,用来做随机采样,生成inbag/outofbag样本(inbag样本为True)
        sample_mask = _random_sample_mask(n_samples, n_inbag, random_state)
        # 获得之前的oob得分
        old_oob_score = loss_(y[~sample_mask], y_pred[~sample_mask], sample_weight[~sample_mask])

        # 调用_fit_stage来训练下一阶段的数
        y_pred = self._fit_stage(i, X, y, y_pred, sample_weight, sample_mask, random_state, X_idx_sorted, X_csc, X_csr)

    # 跟踪偏差/loss
    # 当do_oob时,计算训练样本的loss和oob_score的提升值
    if do_oob:
        # inbag训练样本的loss
        self.train_score_[i] = loss_(y[sample_mask], y_pred[sample_mask], sample_weight[sample_mask])
        # outofbag样本的loss提升
        self.oob_improvement_[i] = (old_oob_score - loss_(y[~sample_mask], y_pred[~sample_mask], sample_weight[~sample_mask]))
    # subsample为1时
    else:
        self.train_score_[i] = loss_(y, y_pred, sample_weight)

    # 若verbose大于0,更新标准输出
    if self.verbose > 0:
        verbose_reporter.update(i, self)
    # ......若有monitor,检查是否需要early_stopping
# _fit_stages方法返回i+1,即迭代总次数(包括warm_start以前的迭代)

_fit_stage方法

# 判断sample_mask的数据类型
assert sample_mask.dtype == np.bool
# 获取损失函数
loss = self.loss_
# 获取目标值 
original_y = y
# 这里K针对的是多分类问题,回归和二分类时K为1
for k in range(loss.K):
    # 当问题是多分类问题时,获取针对该分类的y值
    if loss.is_multi_class:
        y = np.array(original_y == k, dtype=np.float64)
    # 计算当前负梯度
    residual = loss.negative_gradient(y, y_pred, k=k, sample_weight=sample_weight)
    # 构造决策回归树(事实上是对负梯度做决策树模型)
    tree = DecisionTreeRegressor(
        criterion=self.criterion,
        splitter='best',
        max_depth=self.max_depth,
        min_samples_split=self.min_samples_split,
        min_samples_leaf=self.min_samples_leaf,
        min_weight_fraction_leaf=self.min_weight_fraction_leaf,
        min_impurity_split=self.min_impurity_split,
        max_features=self.max_features,
        max_leaf_nodes=self.max_leaf_nodes,
        random_state=random_state,
        presort=self.presort)
    # 如果做sabsample,重新计算sample_weight
    if self.subsample < 1.0:
        sample_weight = sample_weight * sample_mask.astype(np.float64)
    # 根据输入X是否稀疏,采用不同的fit方法,针对负梯度训练决策树
    if X_csc is not None:
        tree.fit(X_csc, residual, sample_weight=sample_weight, check_input=False, X_idx_sorted=X_idx_sorted)
    else:
        tree.fit(X, residual, sample_weight=sample_weight, check_input=False, X_idx_sorted=X_idx_sorted)
    # 根据输入X是否稀疏,使用update_terminal_regions方法更新叶子节点(注意这是LossFunction里的一个方法)
    if X_csr is not None:
        loss.update_terminal_regions(tree.tree_, X_csr, y, residual, y_pred, sample_weight, sample_mask, self.learning_rate, k=k)
    else:
        loss.update_terminal_regions(tree.tree_, X, y, residual, y_pred, sample_weight, sample_mask, self.learning_rate, k=k)
    # 将新的树加入到ensemble模型中
    self.estimators_[i, k] = tree
# _fit_stage方法返回新的预测值y_pred,注意这里y_pred是在loss.update_terminal_regions计算的

LossFunction中的update_terminal_regions方法

为了加深理解,我么我们再看一下update_terminal_regions都做了什么。

# 计算每个样本对应到树的哪一个叶子节点
terminal_regions = tree.apply(X)
# 将outofbag的样本的结果都置为-1(不参与训练过程)
masked_terminal_regions = terminal_regions.copy()
masked_terminal_regions[~sample_mask] = -1
# 更新每个叶子节点上的value,tree.children_left == TREE_LEAF是判断叶子节点的方法。一个很关键的点是这里只更新了叶子节点,而只有LossFunction是LeastSquaresError时训练时生成的决策树上的value和我们实际上想要的某个节点的预测值是一致的。
for leaf in np.where(tree.children_left == TREE_LEAF)[0]:
    # _update_terminal_region由每个具体的损失函数具体实现,在LossFunction基类中只提供模板
    self._update_terminal_region(tree, masked_terminal_regions, leaf, X, y, residual, y_pred[:, k], sample_weight)
# 更新预测值,tree预测的是负梯度值,预测值通过加上学习率 * 负梯度来更新,这里更新所有inbag和outofbag的预测值
y_pred[:, k] += (learning_rate * tree.value[:, 0, 0].take(terminal_regions, axis=0))

笔者之前使用GBDT做回归模型时观察每颗树的可视化结果,发现对于损失函数是ls(LeastSquaresError)的情况,每棵树的任意一个节点上的value都是当前点的target预估值差(即residual,所有树叶子节点预测的都是residual,它们的和是最终的预测结果);但使用lad损失函数时,只有叶子节点的结果是收入预估值差。原因应该就在这里:

  • ls对应的LossFunction类是LeastSquaresError,每个节点的value就是当前点的target预估值差,叶子节点也不需要更新。这是因为ls的负梯度计算方法是预测值和目标值的差,这本身就是residual的概念,所以所有节点的value都是我们想要的值。
  • lad对应的LossFunction类是LeastAbsoluteError,每个节点的value并不是当前点的target预估值差,而最后代码里也只更新了叶子节点,所以可视化时会有一些问题,也不能直接获得每个节点的value作为target预估值差。事实上,lad在训练的时候有点像一个“二分类”问题,它的负梯度只有两种取值-1和1,即预测值比目标值大还是小,然后根据这个标准进行分裂。

所以如果没有改源码并重新训练模型的话,若不是ls,其它已有的GBDT模型没有办法直接获取每个非叶子结点的target预估值差,这个在分析模型时会有一些不方便的地方。

feature_importances_的计算

# 初始化n_feautures_长度的数组
total_sum = np.zeros((self.n_features_, ), dtype=np.float64)
# 对于boosting模型中的每一个estimator(实际上就是一棵树,多分类是多棵树的数组)
for stage in self.estimators_:
    # 当前stage每个feature在各个树内的所有的importance平均(多分类时一个stage有多棵树)
    stage_sum = sum(tree.feature_importances_ for tree in stage) / len(stage)
    # 累加各个stage的importance
    total_sum += stage_sum
# 做归一化
importances = total_sum / len(self.estimators_)

GradientBoostingClassifier

GBDT分类器的loss可取deviance或exponential,分别对应MultinomialDeviance和ExponentialLoss这两个损失函数。分类器在predict时需要多加一步,把不同类别对应的树的打分综合起来才能输出结果。因此GBDT实际上不太适合做多分类问题。

GradientBoostingRegressor

GBDT回归器的loss可取ls, lad, huber, quantile,分别对应LeastSquaresError, LeastAbsoluteError, HuberLossFunction, QuantileLossFunction这几个损失函数。

0xFF 总结

至此,该系列三篇文章已结。


作者:cathyxlyl | 简书 | GITHUB

个人主页:http://cathyxlyl.github.io/
文章可以转载, 但必须以超链接形式标明文章原始出处和作者信息

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容