[白话解析] 通俗解析集成学习之GBDT

2020-09-07 16:31:38 浏览数 (1)

[白话解析] 通俗解析集成学习之GBDT

0x00 摘要

本文将为大家讲解GBDT这个机器学习中非常重要的算法。因为这个算法属于若干算法或者若干思想的结合,所以很难找到一个现实世界的通俗例子来讲解,所以只能少用数学公式来尽量减少理解难度。

0x01 定义 & 简述

我们首先给出定义和概述,让大家有个直观的概念,然后再针对每一个概念和环节做详述。

1. GBDT(Gradient Boosting Decision Tree)= GB DT

首先,我们给出一个定义。

  • GB:Gradient Boosting,一种算法框架,用梯度计算拟合损失函数的提升过程。
  • G:Gradient 梯度,确切地说是Gradient Descent(梯度下降),实现层面上是用损失函数的负梯度来拟合本轮损失函数的近似值,进而拟合得到一个弱学习器。
  • B:Boosting 一种集成学习算法,通过迭代训练一系列弱学习器(每一次训练都是在前面已有模型的预测基础上进行),组合成一个强学习器,来提升回归或分类算法的精确度。"Boosting"的基本思想是通过某种方式使得每一轮基学习器在训练过程中更加关注上一轮学习错误的样本。提升方法 Boosting 采用的是加法模型和前向分布算法来解决分类和回归问题。
  • DT:Decision Tree,决策树,一种常用的用来做回归或分类的算法,可以理解成树状结构的if-else规则的集合。在这里就是上述的弱学习器了。

总结起来,所谓GBDT,就是通过迭代训练一系列决策树,其中每棵决策树拟合的是基于当前已训练好的决策树们(当前模型)所得到损失函数的负梯度值,然后用这些决策树来共同决策,得到最终的结果。

2. 白话简述

我们需要得到一棵决策树,这个树是用残差拟合出来的。为了提高精度,当使用一棵树训练完以后,我们还在它的基础上再去把它的残差拿来做二次加工、三次加工......这样就有了后面的树。

3. 概括要点

损失函数和负梯度

损失函数:机器学习的训练目标是让损失函数最小,损失函数极小化,意味着拟合程度最好,对应的模型参数即为最优参数。

梯度向量:从几何意义上讲,梯度向量就是函数变化增加最快的地方。沿着梯度向量的方向,更加容易找到函数的最大值。反过来说,沿着梯度向量相反的方向,梯度减少最快,也就是更加容易找到函数的最小值。

可以把 GBDT 的求解过程想象成线性模型优化的过程。在线性模型优化的过程中。利用梯度下降我们总是让参数向负梯度的方向移动,一步步的迭代求解,得到最小化的损失函数。即通过梯度下降可以最小化损失函数

残差和负梯度

残差 在数理统计中是指实际观察值与估计值(拟合值)之间的差。残差r=y−f(x)越大,表明前一轮学习器f(x)的结果与真实值y相差较大,那么下一轮学习器通过拟合残差或负梯度,就能纠正之前的学习器犯错较大的地方。

GBDT 是使用负梯度进行boost,残差是一种特例。再准确的说,GBDT的正则化操作中有“学习步长”,即每一步拟合的不是负梯度,而是负梯度的α倍(负梯度与学习率的乘积),这时候拟合目标和残差就更不相同了。

Boosting和负梯度

"Boosting" 的基本思想是通过某种方式使得每一轮基学习器在训练过程中更加关注上一轮学习错误的样本。

"Gradient Boosting" 是用梯度计算拟合损失函数的提升过程。用损失函数的负梯度(截止到当前的梯度)来拟合本轮损失函数的近似值,进而拟合得到一个弱学习器,在迭代的每一步构建的弱学习器都是为了弥补已有模型的不足。最后将所有的弱学习器结合起来,得到一个强学习器。这样通过累加各个学习器使得损失函数减小。

每新建一个树会做如下操作:

代码语言:javascript复制
根据之前的fm和损失函数的负梯度来计算残差 ---> 构建新树来拟合残差 (特征划分 / 更新叶子节点预测值) ---> 更新模型 (计算出f{m 1}) 
两个层面的随机梯度下降

参数梯度下降是根据负梯度调整原来的参数,在参数层面调整;而同样根据负梯度,可以获得一个新的函数/基学习器(通过获得新的参数实现,但是参数的意义是通过函数体现),这就是在函数层面调整。

  • 在参数空间中优化,每次迭代得到参数的增量,这个增量就是负梯度乘上学习率;
  • 在函数空间中优化,每次得到增量函数,这个函数会去拟合负梯度,在GBDT中就是一个个决策树。要得到最终结果,只需要把初始值或者初始的函数加上每次的增量。处理粒度更新参数w,从而更新函数F(X),使得损失函数L(y,F(X))最小。

可以认为 “GB” 是每一轮基学习器在负梯度上逐步趋近最小损失函数

基学习器和负梯度

GBDT 中的这个基学习器是一棵分类回归树(CART),我们可以使用决策树直接拟合梯度。假入我们现在有 t 棵树,我们需要去学习是第 t 1 棵树,那么如何学习第 t 1 棵树才是最优的树呢?这个时候我们参考梯度优化的思想。现在的 t 课树就是我们现在的状态,使用这个状态我们可以计算出现在的损失。如何让损失更小呢?我们只需要让 t 1 棵树去拟合损失的负梯度。正是借用了梯度优化的思想。所以叫梯度提升树。

GBDT解决分类问题时,拟合的都是类别的概率,是一个值,跟逻辑回归的思想差不多;二分类问题中,类别的个数与树的个数肯定是无关的,但多分类问题中,树的个数就等于k*m,k为类别个数,m为对每个类别训练的树的个数;GBDT的多分类问题使用的就是一对多的方法,只要关注训练该类别所使用的m课树的拟合值的汇总结果是否大于阈值即可。

加法模型

提升方法 Boosting 采用的是加法模型和前向分布算法来解决分类和回归问题,实现学习的优化过程。

GBDT是在用forward stage-wise的方式来fit一个additive model。假设我们最终得到一个high performance的模型为F(x),F(x)其实是由多个f(x)累加的。

从additive model 的角度上来看 ,Fm(x) = Fm-1(x) h(x)=y,则h(x) = y - Fm-1(x)即残差,所以每次iteration,一个新的cart 树似乎都是在拟合残差,但只是一个相近值,也是一个比较朴素的想法。

如果损失函数为square error,其导数即是残差的导数。 我们都知道,梯度下降是沿着负梯度方向下降的(一阶泰勒公式展开推导,即我们能通过一阶泰勒展开证明负梯度方向是下降最快的方向)。所以,h(x)去拟合残差即可。 但是如果用absolute error 、huber error等,那么残差就不等于负梯度了。此时即用h(x)来拟合负梯度(一般是负梯度与学习率的乘积)。

boosting 的可加性(additive)。可加性指的是 h 的可加,而不是 x 的可加。比如 x 是决策树,那两棵决策树本身怎么加在一起呢? 你顶多把他们并排放在一起。可加的只是样本根据决策树模型得到的预测值 h(x,D)罢了。

提升树

注意GBDT不论是用于回归还是分类,其基学习器 (即单棵决策树) 都是回归树即使是分类问题也是将最后的预测值映射为概率,因为回归树的预测值累加才是有意义的,而GBDT是把所有树的结论累加起来做最终结论的

GBDT在回归问题中,每轮迭代产生一棵CART(分类和回归树),迭代结束时将得到多棵CART回归树,然后把所有的树加总起来就得到了最终的提升树。

GBDT的核心就在于,每一棵树学的是之前所有树结论和的残差,这个残差就是一个加预测值后能得真实值的累加量。

回归树

每新建一个树 ,先计算残差,再根据残差构建回归树 (也就是拟合残差)。构建树时候特征划分,更新叶子节点预测值,求出使损失函数最小 (也就是拟合最好) 的预测值:

代码语言:javascript复制
for m = 1 to M 循环生成决策树,每新建一个树 :
    (A) 计算更新残差 res_m = label - f_m
    (B) 使用回归树来拟合残差 res_m,叶子结点value就是残差数值。
    (C) 计算更新 “叶子节点预测值“ f_m = f_prev   lr * res_m
        每做一次特征划分,计算SE = (残差res_m - 残差均值),即每棵树对应叶子节点的残差之和。
        针对每个叶子节点样本,计算更新 ”叶子节点预测值“,求出使损失函数最小,也就是拟合叶子节点最好的的输出值
    (D) 更新模型 F_m 

假如我们已经进行了五次迭代,那么这个算法流程中,第五颗树的数据打印如下:

代码语言:javascript复制
第5棵树: mse_loss:0.0285
   id  age  weight  label    f_0  res_1     f_1   res_2      f_2    res_3  
0   1    5      20    1.1  1.475 -0.375  1.4375 -0.3375  1.40375 -0.30375   
1   2    7      30    1.3  1.475 -0.175  1.4575 -0.1575  1.44175 -0.14175   
2   3   21      70    1.7  1.475  0.225  1.4975  0.2025  1.51775  0.18225   
3   4   30      60    1.8  1.475  0.325  1.5075  0.2925  1.53675  0.26325   

        f_3     res_4       f_4     res_5       f_5  
0  1.373375 -0.273375  1.346037 -0.246037  1.321434  
1  1.427575 -0.127575  1.414818 -0.114818  1.403336  
2  1.535975  0.164025  1.552377  0.147622  1.567140  
3  1.563075  0.236925  1.586768  0.213232  1.608091  

这里有两个特征: age, weight。而label数值是1.1,1.3 ,1.7,1.8。

可以看出res是在梯度下降。f_0都初始化为 1.475。假设学习率是0.1。第五颗回归树的叶子结点是res_5,而f_5 = f_4 0.1 * res_4。

本例对应算法在后文中会给出详细代码。

4. 总结

最后总结下 :

  • DT 就是决策树。
  • B 就是连续生成一系列的决策树,后一个决策树在前一个决策树基础上做优化 。
  • G 就是这些决策树拟合的是残差(回归树的叶子结点value是残差数值),这个残差是按照损失函数的负梯度方向步进,一步一步递进之后,使得最后损失函数最小。

0x02 相关概念

下面会逐一详述相关概念,以及其在GBDT如何应用。

1. 损失函数

损失函数(loss function):机器学习中,为了评估模型拟合的好坏,通常用损失函数来度量拟合的程度。比如在线性回归中,损失函数通常为样本输出和假设函数的差取平方。

损失函数极小化,意味着拟合程度最好,对应的模型参数即为最优参数。不同的损失函数代表不同优化目标,像MAE,RMSE,指数损失,交叉熵及其他损失。

2. 残差

残差在数理统计中是指实际观察值与估计值(拟合值)之间的差。即残差(residual)是因变量的观测值 yi 与根据估计的回归方程求出的预测 y^i 之差。

3. 梯度

在微积分里面,对多元函数的参数求∂偏导数,把求得的各个参数的偏导数以向量的形式写出来,就是梯度。

梯度向量从几何意义上讲,就是函数变化增加最快的地方。沿着梯度向量的方向,更加容易找到函数的最大值。反过来说,沿着梯度向量相反的方向,梯度减少最快,也就是更加容易找到函数的最小值。

负梯度也被称为“响应 (response)” 或 “伪残差 (pseudo residual)”,从名字可以看出是一个与残差接近的概念。

直觉上来看,残差 r = y − f(?) 越大,表明前一轮学习器 f(?) 的结果与真实值 y 相差较大,那么下一轮学习器通过拟合残差或负梯度,就能纠正之前的学习器犯错较大的地方。

4. 梯度下降

在机器学习算法中,在最小化损失函数时,可以通过梯度下降法来一步步的迭代求解,得到最小化的损失函数,和模型参数值。反过来,如果我们需要求解损失函数的最大值,这时就需要用梯度上升法来迭代了。

梯度下降法和梯度上升法是可以互相转化的。比如我们需要求解损失函数f(θ)的最小值,这时我们需要用梯度下降法来迭代求解。但是实际上,我们可以反过来求解损失函数 -f(θ)的最大值,这时梯度上升法就派上用场了。

首先来看看梯度下降的一个直观的解释。

比如我们在一座大山上的某处位置,由于我们不知道怎么下山,于是决定走一步算一步,也就是在每走到一个位置的时候,求解当前位置的梯度,沿着梯度的负方向,也就是当前最陡峭的位置向下走一步,然后继续求解当前位置梯度,向这一步所在位置沿着最陡峭最易下山的位置走一步。这样一步步的走下去,一直走到觉得我们已经到了山脚。当然这样走下去,有可能我们不能走到山脚,而是到了某一个局部的山峰低处。

从上面的解释可以看出,梯度下降不一定能够找到全局的最优解,有可能是一个局部最优解。当然,如果损失函数是凸函数,梯度下降法得到的解就一定是全局最优解。

5. 在参数空间最优参数估计

对于“在参数空间进行最优参数点估计”这种问题,有一种解法是梯度下降法(Steepest Gradient Descent,SGD),采用分布加和扩展的方案:其实就是给定起点后,贪心寻找最优解。这里的贪心是指每步都是在上一步的基础上往函数下降最快的方向走。

  • 给定一个初始点 x0
  • 对 i = 1 , 2, ... , n 分别做如下迭代
  • x i = x {i - 1} bi * gi,其中gi表示 f 在 x {i - 1} 上的负梯度值,bi 是步长 ,是 通过在 gi 方向线性搜索来动态调整 的。
  • 一直到 |gi|足够小,或者 |x i - x {i - 1} |足够小,即函数收敛

其实就是给定起点后,贪心寻找最优解。这里的贪心是指每步都是在上一步的基础上往函数下降最快的方向走。

寻得的解可以表示为:

x k = x0 b1 * g1 ... bk * gk

6. 在函数空间最优函数估计

以上是在参数空间进行最优参数点估计,这个思路能不能推广到函数空间,进行最优函数估计呢?

看看一般函数估计问题。函数估计的目标是得到使得所有训练样本在(y,x)的联合分布上,最小化期望损失函数。

对于给定的损失函数L(y,F),迭代求解基学习器时,损失函数不断变小越小,Fm也就越靠近F,使得损失函数极速下降的最优方向,就是负梯度。

其实仔细想想,这个和常规的的利用梯度下降最小化损失函数过程一致。数值型解析解不能一步到位求出来,用梯度下降一步一步贪心近似;而我们的模型不能一步到位求出来,就用boosting的方式一步一步地近似出理想模型。参数梯度下降是根据负梯度调整原来的参数,在参数层面调整;而此处根据负梯度,获得一个新的函数/基学习器(通过获得新的参数实现,但是参数的意义通过函数体现),在函数层面调整

7. 为什么前向分步时不直接拟合残差?

GBDT并不是用负梯度代替残差!!!GBDT建树时拟合的是负梯度!

学习负梯度才能保证符合Boosting的基本要求

搞清楚两个问题

  • Boosting的基本要求是:随着迭代次数的增多,Loss只能递减(而不能又在某个点突然增大)
  • Boosting的本质是通过累加各个学习器使得损失函数减小,而不是使训练样本拟合的更好

因此我们的目标是达成Boosting的基本要求而不是学什么残差(Boosting算法概念里就没有残差这个东西),而是这个算法就是根据梯度下降的思想设计出来的。

在Freidman之前,发明了AdaBoost和GBDT,但是却发现例如GBDT的Loss函数一旦不再是平方差时,如果还是学习残差就不能满足Boosting的基本要求。Freidman通过对loss泰勒一阶展开发现了真正的奥秘是应该学习负梯度才能保证符合Boosting的基本要求。即利用损失函数的负梯度作为在当前模型的值作为残差的近似值,这样就能保证每次迭代过程损失函数不断减小,所以第m颗基树拟合负梯度就能解决问题。

GBDT本身就是使用负梯度进行boost,残差反而是一种特例。更具体的说,损失函数为平方损失函数时梯度值恰好是残差。不过这是一个特例,其它的损失函数就不会有这样的性质。使用残差这个说法解释GBDT更容易理解,毕竟符合人的认知,也就是更具象化。

负梯度可以扩展到更复杂的损失函数

首先,残差只针对于平方损失函数,脱离这个前提,残差与负梯度本身就是完全的两个概念。

其次,在有些损失函数中(比如Huber loss),残差与梯度是不等的,残差比梯度更完备,考虑了噪声或者outliers的情况。

并且,对于更复杂的损失函数,“残差”往往是比梯度更加难求的。

再加上对GDBT的正则化操作中有“学习步长”,即每一步拟合的不是负梯度,而是负梯度的α倍,这时候拟合目标和残差就更不相同了。

提升树用加法模型与前向分布算法实现学习的优化过程。当损失函数为平方损失和指数损失函数时,每一步优化是很简单的。但对于一般损失函数而言,往往每一步都不那么容易。对于这问题,Freidman提出了梯度提升算法。这是利用最速下降法的近似方法,其关键是利用损失函数的负梯度在当前模型的值

负梯度来替代残差是对损失函数的普及化。真正的目的是使损失函数快速减小。由于模型本身是加性模型,将当前迭代(第m次)损失函数在前一代(m-1次)损失处进行泰勒一介展开。

8. 决策树

决策树是一种机器学习的方法,一种模型,它的主要思想是将输入空间划分为不同的子区域,然后给每个子区域确定一个值,它是一种树形结构,其中每个内部节点表示一个属性上的判断,每个分支代表一个判断结果的输出,最后每个叶节点代表一种分类结果。如果是分类树,这个值就是类别,如果是回归树,这个树就是一个实值。

9. 回归树拟合

GBDT中的树拟合的是负梯度,都是CART回归树,不是分类树,因为GBDT的核心在于累加所有树的结果作为最终结果,而只有回归树的结果可以累加,分类树的结果进行累加是没有意义的。尽管GBDT调整后也可以用于分类,但这不代表GBDT中用到的决策树是分类树。

由于GBDT的学习过程是通过多轮迭代,每次都在上一轮训练结果的残差的基础上进行学习,于是要求基学习器要足够简单,具有高偏差、低方差的特点。GBDT的基学习器是CART回归树,由于高偏差和简单的要求,每棵CART回归树的深度不会很深。

提升树的每次迭代,就是用一棵决策树去拟合上一轮训练的残差,每一个棵回归树拟合的目标是损失函数的负梯度在当前模型的值。而之前所有树的预测值的累加值,加上这个残差就等于真实值。

比如A的真实年龄是18岁,第一棵树预测的年龄是12岁,那么残差是6岁,6岁作为第二棵树学习的目标。如果第二棵树的预测年龄是5岁,那么残差等于真实年龄减去这两棵树的预测值之和(18-12-5),即为1。于是第三棵树中A的年龄变成了1岁,继续去学习,越来越逼近18岁这个目标。如果恰巧在第m棵树时,残差为0,那么累加这m棵树预测的年龄,就和真实的年龄完全相等了。

训练的过程就是通过降低偏差来不断提高最终的提升树进行分类和回归的精度,使整体趋近于低偏差、低方差。最终的提升树就是将每轮训练得到的CART回归树加总求和得到(也就是加法模型)。

10. 单棵回归树

每次迭代都会建立一个回归树去拟合负梯度向量,与建树相关的点有:

  • 损失函数,比如均方差损失函数,决定树的输出。
  • 切分准则(fitting criterion/splitting criterion),决定树的结构。比如通常使用的是friedman_mse原则,公式为Greedy Function Approximation: A Gradient Boosting Machine论文中的(35)式
  • 叶子节点的值,叶子节点的值为分到该叶子节点的所有样本对应的输出yi的平均值。

根据划分函数得到的输出值主要作用是用来建树,树的结构确定后,再极小化损失函数,得到叶子节点的输出值,这个输出值才是回归树的输出值。如果损失函数是平方误差,树节点的输出值恰好也是要拟合的yi的均值。

对于回归树算法来说最重要的是寻找最佳的划分点,那么回归树中的可划分点包含了所有特征的所有可取的值。在分类树中最佳划分点的判别标准是熵或者基尼系数,都是用纯度来衡量的,但是在回归树中的样本标签是连续数值,所以再使用熵之类的指标不再合适,取而代之的是平方误差,它能很好的评判拟合程度。

11. 加法模型

加法模型就是基学习器的一种线性组合,也是一种模型集H。它的一般形式如下: h(x) = β1 . f_1(x) β2 . f_2(x) β3 . f_3(x) ... β_m . f_m(x) ,即

[h(x) = sum_{m =1}^M β_m . f_m(x) ]

f_m(x)叫做基函数,基函数可以有各种各样的形式,自然也会有自己的参数,我们讨论GBDT时,它就是二叉回归决策树。β_m是基函数的系数,一般假设大于0。

有了模型,还需定义该模型的经验损失函数:

[sum_{i =1}^N L [y_i, sum_{m =1}^M β_m . f_m(x_i)] ]

现在,我们的问题转变成了通过极小化经验损失函数来确定各个系数β_m和各个基函数f_m(x)。

即如何求出f_m和β_m?

12. 前向分步算法

前向分布算法说:“我可以提供一套框架,不管基函数和损失函数是什么形式,只要你的模型是加法模型,就可以按照我的框架的指导,去求解。”

也就是说,前向分步算法提供了一种学习加法模型的普遍性方法,不同形式的基函数、不同形式的损失函数都可以用这种普遍性方法去求出加法模型的最优化参数,它是一种元算法。

它的思路是:加法模型中一共有M个基函数以及与之相应的M个系数,可以从前往后,每次学习一个基函数及其系数。

提升树的前向分步算法。第m步的模型可以写成:

fm(x)=fm−1(x) T(x;β_m)

然后得到损失函数:

?(??(?),?)=?(??−1(?) ?(?;β_m),?)

前向分步算法求解这问题的思路:因为学习的是加法模型,如果能够从前向后,每一步只学习一个基函数及其系数,逐步去逼近上述的目标函数式,就可简化优化的复杂度,每一步只需优化损失函数。

可见,前向分步算法将同时求解从m=1到M所有参数的优化问题简化成逐步求解各个参数的优化问题了。

迭代的目的是构建T(x;β_m),使得本轮损失L(fm(x),y)最小。

13. 梯度下降

思想其实并不复杂,但是问题也很明显,对于不同的任务会有不同的损失函数,损失函数各种各样,对各种损失函数的残差进行拟合并不容易,怎么找到一种通用的拟合方法呢?针对这个问题,大牛Freidman提出了用损失函数的负梯度来拟合本轮损失的近似值,进而拟合一个CART回归树。

通过损失函数的负梯度来拟合,我们找到了一种通用的拟合损失误差的办法,这样无论是分类问题还是回归问题,我们通过其损失函数的负梯度的拟合,就可以用GBDT来解决我们的分类回归问题。区别仅仅在于损失函数不同导致的负梯度不同而已。

于是在GBDT中,就使用损失函数的负梯度作为提升树算法中残差的近似值,然后每次迭代时,都去拟合损失函数在当前模型下的负梯度。这就找到了一种通用的拟合方法

为什么通过拟合负梯度就能纠正上一轮的错误了?Gradient Boosting的发明者给出的答案是:函数空间的梯度下降。负梯度永远是函数下降最快的方向,自然也是gbdt目标函数下降最快的方向,所以用负梯度去拟合首先是没什么问题的。

14. GBDT的优势

那么GBDT的优势有两个方面:

1 特征组合和发现重要特征 1)特征组合: 原始特征经过GBDT转变成高维稀疏特征(GBDT的输出相当于对原始特征进行了特征组合,得到高阶特征或者说是非线性映射),然后将这些新特征作为FM(Factorization Machine)或LR(逻辑回归)的输入再次进行拟合。 2)发现重要特征: 由于决策树的生长过程就是不断地选择特征、分割特征,因此由大量决策树组成的GBDT具有先天的优势,可以很容易得到特征的重要度排序,且解释性很强。

2 泛化能力强 泛化误差可以分解为两部分,偏差(bias)和方差(variance)。 1)为了保证低偏差bias,采用了Boosting,每一步我们都会在上一轮的基础上更加拟合原数据,可以保证低偏差; 2)为了保证低方差,采用了简单的模型,如深度很浅的决策树。 两者结合,就能基于泛化性能相当弱的学习器构建出泛华能力很强的集成模型。

15. 算法步骤概要

GBDT每一次建立模型,是在之前建立模型损失函数的梯度下降方向。损失函数描述的是模型的不靠谱程度,损失函数越大,说明模型越容易出错。如果我们的模型能够让损失函数持续的下降,说明我们的模型在不停的改进,而最好的方式就是让损失函数在其梯度的方向下降。

一般的梯度下降是以一个样本点(xi,yi)作为处理的粒度,w是参数,f(w;x)是目标函数,即减小损失函数L(yi,f(xi;w)),优化过程就是不断处理参数w(这里用到梯度下降),使得损失函数L最小;GB是以一个函数作为处理粒度,对上一代的到的函数或者模型F(X)求梯度式,即求导,决定下降方向。针对每一个叶子节点里的样本,我们计算更新叶子节点预测值,求出使损失函数最小,也就是拟合叶子节点最好的的输出值

构建分类 GBDT 的步骤是下面两个:

  1. 初始化 GBDT
  2. 循环生成决策树

我们把分类 GBDT 第二步也可以分成四个子步骤:(A)、(B)、(C)、(D),写成伪代码:

代码语言:javascript复制
for m = 1 to M 循环生成决策树,每新建一个树 :
    (A) 计算更新残差 res_m = label - f_m
    (B) 使用回归树来拟合残差 res_m
    (C) 计算更新叶子节点预测值 f_m = f_prev   lr * res_m
        每做一次特征划分,计算SE = (残差res_m-残差均值)每棵树对应叶子节点的残差之和
    (D) 更新模型 F_m
      
其中 m 表示第 m 棵树,M 为树的个数上限,我们先来看

(A):计算残差
此处为使用 m-1 棵树的模型,计算每个样本的残差 r_{im},这里的偏微分实际上就是求每个样本的梯度,因为梯度我们已经计算过了,即 -y_i p_i,那么 r_{im}=y_i-p_i,

(B):使用回归树来拟合 r_{im}

(C):对每个叶子节点 j,计算更新叶子节点预测值。意思是,在刚构建的树 m 中,找到每个节点 j 的输出,能使该节点的 Loss 最小。

(D):更新模型 F_m(x)

仔细观察该式,实际上它就是梯度下降——「加上残差」和「减去梯度」这两个操作是等价的

最终,循环 M 次后,或总残差低于预设的阈值时,我们的分类 GBDT 建模便完成了。

0x03 代码示例

本代码出自 https://github.com/Freemanzxp/GBDT_Simple_Tutorial。如果大家想依据代码来学习,这个还是非常推荐的。

1. 训练

代码语言:javascript复制
class AbstractBaseGradientBoosting(metaclass=abc.ABCMeta):
    def __init__(self):
        pass
    def fit(self, data):
        pass
    def predict(self, data):
        pass

class BaseGradientBoosting(AbstractBaseGradientBoosting):

    def __init__(self, loss, learning_rate, n_trees, max_depth,
                 min_samples_split=2, is_log=False, is_plot=False):
        super().__init__()
        self.loss = loss
        self.learning_rate = learning_rate
        self.n_trees = n_trees
        self.max_depth = max_depth
        self.min_samples_split = min_samples_split
        self.features = None
        self.trees = {}
        self.f_0 = {}
        self.is_log = is_log
        self.is_plot = is_plot

    def fit(self, data):
        """
        :param data: pandas.DataFrame, the features data of train training   
        """
        # 掐头去尾, 删除id和label,得到特征名称
        self.features = list(data.columns)[1: -1]
        # 初始化 f_0(x)
        # 对于平方损失来说,初始化 f_0(x) 就是 y 的均值
        self.f_0 = self.loss.initialize_f_0(data)
        # 对 m = 1, 2, ..., M
        logger.handlers[0].setLevel(logging.INFO if self.is_log else logging.CRITICAL)
        for iter in range(1, self.n_trees 1):
            # 计算负梯度--对于平方误差来说就是残差
            self.loss.calculate_residual(data, iter)
            target_name = 'res_'   str(iter)
            self.trees[iter] = Tree(data, self.max_depth, self.min_samples_split,
                                    self.features, self.loss, target_name, logger)
            self.loss.update_f_m(data, self.trees, iter, self.learning_rate, logger)
            if self.is_plot:
                plot_tree(self.trees[iter], max_depth=self.max_depth, iter=iter)
        # print(self.trees)
        if self.is_plot:
            plot_all_trees(self.n_trees)

2. 损失函数

代码语言:javascript复制
class LossFunction(metaclass=abc.ABCMeta):

    @abc.abstractmethod
    def initialize_f_0(self, data):
        """初始化 F_0 """

    @abc.abstractmethod
    def calculate_residual(self, data, iter):
        """计算负梯度"""

    @abc.abstractmethod
    def update_f_m(self, data, trees, iter, learning_rate, logger):
        """计算 F_m """

    @abc.abstractmethod
    def update_leaf_values(self, targets, y):
        """更新叶子节点的预测值"""

    @abc.abstractmethod
    def get_train_loss(self, y, f, iter, logger):
        """计算训练损失"""

class SquaresError(LossFunction):

    def initialize_f_0(self, data):
        data['f_0'] = data['label'].mean()
        return data['label'].mean()

    def calculate_residual(self, data, iter):
        res_name = 'res_'   str(iter)
        f_prev_name = 'f_'   str(iter - 1)
        data[res_name] = data['label'] - data[f_prev_name]

    def update_f_m(self, data, trees, iter, learning_rate, logger):
        f_prev_name = 'f_'   str(iter - 1)
        f_m_name = 'f_'   str(iter)
        data[f_m_name] = data[f_prev_name]
        for leaf_node in trees[iter].leaf_nodes:
            data.loc[leaf_node.data_index, f_m_name]  = learning_rate * leaf_node.predict_value
        # 打印每棵树的 train loss
        self.get_train_loss(data['label'], data[f_m_name], iter, logger)

    def update_leaf_values(self, targets, y):
        return targets.mean()

    def get_train_loss(self, y, f, iter, logger):
        loss = ((y - f) ** 2).mean()


class BinomialDeviance(LossFunction):

    def initialize_f_0(self, data):
        pos = data['label'].sum()
        neg = data.shape[0] - pos
        # 此处log是以e为底,也就是ln
        f_0 = math.log(pos / neg)
        data['f_0'] = f_0
        return f_0

    def calculate_residual(self, data, iter):
        # calculate negative gradient
        res_name = 'res_'   str(iter)
        f_prev_name = 'f_'   str(iter - 1)
        data[res_name] = data['label'] - 1 / (1   data[f_prev_name].apply(lambda x: math.exp(-x)))

    def update_f_m(self, data, trees, iter, learning_rate, logger):
        f_prev_name = 'f_'   str(iter - 1)
        f_m_name = 'f_'   str(iter)
        data[f_m_name] = data[f_prev_name]
        for leaf_node in trees[iter].leaf_nodes:
            data.loc[leaf_node.data_index, f_m_name]  = learning_rate * leaf_node.predict_value
        # 打印每棵树的 train loss
        self.get_train_loss(data['label'], data[f_m_name], iter, logger)

    def update_leaf_values(self, targets, y):
        numerator = targets.sum()
        if numerator == 0:
            return 0.0
        denominator = ((y - targets) * (1 - y   targets)).sum()
        if abs(denominator) < 1e-150:
            return 0.0
        else:
            return numerator / denominator

    def get_train_loss(self, y, f, iter, logger):
        loss = -2.0 * ((y * f) - f.apply(lambda x: math.exp(1 x))).mean()
        logger.info(('第%d棵树: log-likelihood:%.4f' % (iter, loss)))


class MultinomialDeviance:

    def init_classes(self, classes):
        self.classes = classes

    @abc.abstractmethod
    def initialize_f_0(self, data, class_name):
        label_name = 'label_'   class_name
        f_name = 'f_'   class_name   '_0'
        class_counts = data[label_name].sum()
        f_0 = class_counts / len(data)
        data[f_name] = f_0
        return f_0

    def calculate_residual(self, data, iter):
        # calculate negative gradient
        data['sum_exp'] = data.apply(lambda x:
                                     sum([math.exp(x['f_'   i   '_'   str(iter - 1)]) for i in self.classes]),
                                     axis=1)
        for class_name in self.classes:
            label_name = 'label_'   class_name
            res_name = 'res_'   class_name   '_'   str(iter)
            f_prev_name = 'f_'   class_name   '_'   str(iter - 1)
            data[res_name] = data[label_name] - math.e ** data[f_prev_name] / data['sum_exp']

    def update_f_m(self, data, trees, iter, class_name, learning_rate, logger):
        f_prev_name = 'f_'   class_name   '_'   str(iter - 1)
        f_m_name = 'f_'   class_name   '_'   str(iter)
        data[f_m_name] = data[f_prev_name]
        for leaf_node in trees[iter][class_name].leaf_nodes:
            data.loc[leaf_node.data_index, f_m_name]  = learning_rate * leaf_node.predict_value
        # 打印每棵树的 train loss
        self.get_train_loss(data['label'], data[f_m_name], iter, logger)

    def update_leaf_values(self, targets, y):
        numerator = targets.sum()
        if numerator == 0:
            return 0.0
        numerator *= (self.classes.size - 1) / self.classes.size
        denominator = ((y - targets) * (1 - y   targets)).sum()
        if abs(denominator) < 1e-150:
            return 0.0
        else:
            return numerator / denominator

    def get_train_loss(self, y, f, iter, logger):
        loss = -2.0 * ((y * f) - f.apply(lambda x: math.exp(1 x))).mean()

0x04 参考

GBDT原理详解 Boosting(提升方法)之GBDT 梯度提升树(GBDT)原理小结 GBDT回归篇 GBDT二分类 GBDT多分类 梯度提升树(GBDT)原理小结 - 刘建平Pinard - 博客园 GBDT详解 - 白开水加糖 - 博客园 Regularization on GBDT 梯度下降(Gradient Descent)小结 Boosting(提升方法)之GBDT AdaBoost原理详解 梯度提升树(GBDT)原理小结 AdaBoost & GradientBoost(&GBDT) 从0到1认识GBDT 关于GBDT的几个不理解的地方? gbdt的残差为什么用负梯度代替? GBDT与梯度的理解 GBDT算法梳理 传统推荐算法(六)Facebook的GBDT LR模型(1)剑指GBDT 数据挖掘面试题之梯度提升树 从0到1认识GBDT gbdt心得 gbdt的残差为什么用负梯度代替? 决策树之 GBDT 算法 GBDT算法原理以及实例理解 GBDT模型

0 人点赞