机器学习系列--GBDT算法总结与源码分析

机器学习算法总结 专栏收录该内容
2 篇文章 0 订阅

最近在看Kaggle2014年的一个比赛–Display Advertising Challenge。三个台湾人得了比赛的第一名,他们使用的是FFM算法(这个后面再做总结),在他们比赛的代码中,使用了GBDT算法进行了特征的处理。他们没有使用scikit-learn中封装好的算法,而是自己手撸了一个GBDT的实现。下面就GBDT的一些原理和源码进行分析总结。

1. GBDT算法总结

  梯度提升决策树GBDT(Gradient Boosting Decision Tree)最早由Friedman文章“Greedy Function Approximation: A Gradient Boosting Machine”提出这个概念。GBDT中的树用的是CART回归树(不是分类树),GBDT用来做 回归预测,调整后也可以用于分类。由于GBDT中的CART树,在模型训练的时候,需要逐个训练样本进行计算,模型的训练时间相当之长。因此,这个也决定了GBDT不适合实时的线上训练,更加适用于离散的场景。
  GBDT的思想使其具有天然优势可以发现多种有区分性的特征以及特征组合。Facebook(Practical Lessons From Predicting Clicks on Ads at Facebook)使用其来自动发现有效的特征、特征组合,来作为LR模型中的特征,以提高 CTR预估(Click-Through Rate Prediction)的准确性。GBDT在万能的淘宝搜索及预测业务上也发挥了重要作用。

1.1-Bagging和Boosting

  要想理解清楚GBDT,首先要明白Bagging和Boosting的区别与联系。Bagging和Boosting都是将已有的分类或回归算法通过一定方式组合起来,形成一个性能更加强大的分类器,更准确的说这是一种分类算法的组装方法。即将弱分类器组装成强分类器的方法。

Bagging即套袋法,其算法过程如下:
A)从原始样本集中抽取训练集。每轮从原始样本集中使用Bootstraping的方法抽取n个训练样本(在训练集中,有些样本可能被多次抽取到,而有些样本可能一次都没有被抽中)。共进行k轮抽取,得到k个训练集。(k个训练集之间是相互独立的)
B)每次使用一个训练集得到一个模型,k个训练集共得到k个模型。(注:这里并没有具体的分类算法或回归方法,我们可以根据具体问题采用不同的分类或回归方法,如决策树、感知器等)
C)对分类问题:将上步得到的k个模型采用投票的方式得到分类结果;对回归问题,计算上述模型的均值作为最后的结果。(所有模型的重要性相同)

关于Boosting的两个核心问题:
A)在每一轮如何改变训练数据的权值或概率分布?
通过提高那些在前一轮被弱分类器分错样例的权值,减小前一轮分对样例的权值,来使得分类器对误分的数据有较好的效果。
B)通过什么方式来组合弱分类器?
通过加法模型将弱分类器进行线性组合,比如AdaBoost通过加权多数表决的方式,即增大错误率小的分类器的权值,同时减小错误率较大的分类器的权值。而提升树通过拟合残差的方式逐步减小残差,将每一步生成的模型叠加得到最终模型。
  总的来说,Bagging的训练样本在每次训练的时候,是通过抽样采取;而Boosting的核心是每次训练样本都是一样的,但是训练时候的训练样本的权重不一样。
1)Bagging + 决策树 = 随机森林
2)AdaBoost + 决策树 = 提升树
3)Gradient Boosting + 决策树 = GBDT

1.2-CART回归树

  在机器学习算法中,决策树的种类有很多。最早使用的是ID3算法,之后又陆续的提出C4.5算法和CART算法,这是三个比较常用的决策树算法。ID3算法十分简单,核心是根据“最大信息熵增益”原则选择划分当前数据集的最好特征。ID3采用的信息增益度量存在一个缺点,它一般会优先选择有较多属性值的Feature,因为属性值多的Feature会有相对较大的信息增益?(信息增益反映的给定一个条件以后不确定性减少的程度,必然是分得越细的数据集确定性更高,也就是条件熵越小,信息增益越大).为了避免这个不足C4.5中是用信息增益比率(gain ratio)来作为选择分支的准则。CART树是二叉树,既可以用于分类,也可以用于回归问题,最先由 Breiman 等提出,分类树的输出是样本的类别, 回归树的输出是一个实数。GBDT中使用的是CART回归树,这里我们详细分析一下回归树的算法,其他算法感兴趣的同学可以查看相关的文献。

回归树的生成
具体的推导过程可以参考李航博士的统计学习方法。这个算法应该说是很easy的,稍做说明的就是输出值选择该节点样本点的平均值可以通过求导轻松得到相应的结论。由于决策树很容易产生过拟合的现象,在生成CART树后,还需要进行剪枝操作,生成一系列的回归树,之后通过交叉验证,选择效果相对较好的决策树。
回归树算法描述

1.3-GBDT算法

  有了前面的CART回归树,就可以正式进入GBDT的算法了。GBDT算法是通过逐轮的迭代生成一系列的树,最终的结果是这一系列的树的加权求和。假设我们前一轮迭代得到的强学习器是 Fm1(x) ,损失函数是 L(y,Fm1(x)) 。我们本轮迭代的目标是找到一个CART回归树模型的弱学习器 h(x,am) (其中 am 是CART树的参数),使得 L(y,Fm(x))=L(y,Fm1(x)+h(x,am)) 最小,也就是说,本轮迭代找到决策树,要让样本的损失尽量变得更小。
  GBDT的思想可以用一个通俗的例子解释,假如有个人30岁,我们首先用20岁去拟合,发现损失有10岁,这时我们用6岁去拟合剩下的损失,发现差距还有4岁,第三轮我们用3岁拟合剩下的差距,差距就只有一岁了。如果我们的迭代轮数还没有完,可以继续迭代下面,每一轮迭代,拟合的岁数误差都会减小。
  那么现在的难点是在GBDT中如何去量我们每一轮的损失啊。大牛Freidman提出了用损失函数的负梯度来拟合本轮损失的近似值,进而拟合一个CART回归树。
我自己的理解(一家之言,仅供参考)是可以从损失函数泰勒展开的角度来理解Freidman大牛的做法。将损失函数进行泰勒展开,得到如下结果:

L(y,Fm(x))=L(y,Fm1(x)+h(x,am))=L(y,Fm1(x))+L(y,Fm1(x))Fm1h(x,am)

要想保证等号左边的取值小于等号的右边,即 L(y,Fm1(x))Fm1h(x,am)<0 恒成立,又因为 h(x,am) 是将要计算的CART树,这个是未知,只有梯度是已知的,因此不妨假设要拟合的CART树就是梯度的负方向,即 h(x,am)=L(y,Fm1(x))Fm1 这个就可以保证上面的等式恒成立。

GBDT中使用的损失函数是 L(y,F)=log(1+exp(2yF),y{1,1} ,有关损失函数,可以参考新浪微博的赵志勇总结的机器学习中损失函数。GBDT中损失函数的梯度的负方向为:

yi~=rim=[L(yi,F(xi)))F(xi)]F(x)=Fm1(x)=2yi(1+exp(2yiFm1(x)))

利用 (xi,rim)(i=1,2,..N) ,我们可以拟合一颗CART回归树,得到了第m颗回归树。其对应的叶节点区域 γjm,j=1,2,...,J 。其中 J 为叶子节点的个数。针对每一个叶子节点里的样本,我们求出使损失函数最小,也就是拟合叶子节点最好的的输出值γjm如下:
γjm=argmincxiRjmL(yi,Fm1(xi)+γ)=argmincxiRjmlog(1+exp(2yi(Fm1(xi)+γ)))

这样我们就得到了本轮的决策树拟合函数如下( 在此说明一下,我们上面提到的CART数算法中不是说的是叶子节点的最后输出值是该节点样本的均值吗?为什么在这我们还要求呢?这个问题可以这么理解,Freidman大牛的原始论文中还有一个学习率 ρ ,就是说CART树的输出还要乘上一个学习率,在这进一步的缩写就是说CART树的输出和学习率的乘积看成了CART回归树的最终输出,这样就可以避免了学习率的设置):
h(x,am)=j=1JγjmI(xRjm)

从而本轮最终得到的强学习器的表达式如下:
Fm(x)=Fm1(x)+j=1JcjmI(xRjm)

以上就是GBDT的核心的公式了,还有一些细节我们做一下说明。首先就是初始值,初始值我们应该怎么取?令损失函数其偏导为0,偏导等于零才有可能取到极值。
F0(x)=argminj=1NL(yi,F(xi))j=1NL(yi,F(xi))F=0

求解上述方程
j=1N(2yi)e2yiFe2yiF+1=0i:yi=12e2Fe2F+1+i:yi=12e2Fe2F+1=0

假设训练集中有 m 个正样本,n个负样本,此时上式可以化简为:
2m+2ne2Fe2F+1=0e2F=mn=1+mnm+n1mnm+n=1+y¯1y¯

y¯ 正好是样本的均值, F0(x)=12ln1+y¯1y¯
第二个问题就是 γjm 的求解,由于 γjm 是非线性函数,很难求其最小值,这个时候我们可以借助牛顿法求解其近似解。定义
g(γ)=xiRjmlog(1+exp(2yi(Fm1(xi)+γ)))

其一二阶偏导如下:
g(γ)=xiRjm2yi1+exp(2yi(Fm1(xi)+γ))g′′(γ)=xiRjm4exp(2yi(Fm1(xi)+γ))[1+exp(2yi(Fm1(xi)+γ))]2

利用牛顿迭代法进行迭代一步 γjm=γ0g(γ0)g′′(γ0) 。初始时,可以从 γ0=0 开始迭代, γjm=xiRjmyi~xiRjm|yi~|(2|yi~|) 。到此为止,GBDT算法所有的公式都已经知道如何计算了,下面就是按照如下的算法进行计算:
GBDT算法描述

2. GBDT源码分析

在这主要分析的是14年kaggle比赛中使用到的代码,这个代码中没有CART树剪枝的过程。用到的是比赛中真实的数据,数据前期利用Python进行了清洗处理。具体的可以参考作者的Github。以下仅仅是核心代码的分析,整体的代码分析可以参考我的Github–https://github.com/horizonheart/GBDT

void GBDT::fit(Problem const &Tr, Problem const &Va)
{
     bias = calc_bias(Tr.Y);//计算初始值F0

std::vector<float> F_Tr(Tr.nr_instance, bias), F_Va(Va.nr_instance, bias);

Timer timer;
printf("iter     time    tr_loss    va_loss\n");
// 开始训练每一棵CART树
for(uint32_t t = 0; t < trees.size(); ++t)
{
    timer.tic();

    std::vector<float> const &Y = Tr.Y;
    std::vector<float> R(Tr.nr_instance), F1(Tr.nr_instance);// 记录残差和F(生成树) F1即F_{m-1}

    #pragma omp parallel for schedule(static)
    for(uint32_t i = 0; i < Tr.nr_instance; ++i) 
        R[i] = static_cast<float>(Y[i]/(1+exp(Y[i]*F_Tr[i])));//计算残差,或者称为梯度下降的方向
    // 利用上面的残差值,在此函数中构造一棵树
    trees[t].fit(Tr, R, F1); // 分类树的生成

    // 用上面训练的结果更新F_Tr,并计算log_loss
    double Tr_loss = 0;
    #pragma omp parallel for schedule(static) reduction(+: Tr_loss)
    for(uint32_t i = 0; i < Tr.nr_instance; ++i) 
    {
        F_Tr[i] += F1[i];
        Tr_loss += log(1+exp(-Y[i]*F_Tr[i]));
    }
    Tr_loss /= static_cast<double>(Tr.nr_instance);
     /// 用上面训练的结果预测测试集,打印log_loss
    #pragma omp parallel for schedule(static)
    for(uint32_t i = 0; i < Va.nr_instance; ++i)
    {
        std::vector<float> x = construct_instance(Va, i);
        F_Va[i] += trees[t].predict(x.data()).second;
    }

    double Va_loss = 0;
    #pragma omp parallel for schedule(static) reduction(+: Va_loss)
    for(uint32_t i = 0; i < Va.nr_instance; ++i) 
        Va_loss += log(1+exp(-Va.Y[i]*F_Va[i]));
    Va_loss /= static_cast<double>(Va.nr_instance);

    printf("%4d %8.1f %10.5f %10.5f\n", t, timer.toc(), Tr_loss, Va_loss);
    fflush(stdout);
  }
}
//************************************
// Method:    fit
// FullName:  CART::fit
// Access:    public 
// Returns:   void
// Qualifier: 根据残差训练CART树
// Parameter: Problem const & prob
// Parameter: std::vector<float> const & R 残差,负梯度方向
// Parameter: std::vector<float> & F1 上一步计算的值,相当于F_{m-1}步的值
//************************************
void CART::fit(Problem const &prob, std::vector<float> const &R, 
    std::vector<float> &F1)
{
    uint32_t const nr_field = prob.nr_field;//特征的个数
    uint32_t const nr_sparse_field = prob.nr_sparse_field;
    uint32_t const nr_instance = prob.nr_instance;//样本的个数

std::vector<Location> locations(nr_instance);  // 样本信息
#pragma omp parallel for schedule(static)
for(uint32_t i = 0; i < nr_instance; ++i)
    locations[i].r = R[i]; // 记录每一个样本的残差
for(uint32_t d = 0, offset = 1; d < max_depth; ++d, offset *= 2) // d:深度 offset其实就是每一层有多少个节点
{
    uint32_t const nr_leaf = static_cast<uint32_t>(pow(2, d));// 叶子节点的个数
    std::vector<Meta> metas0(nr_leaf); // 叶子节点的信息

    //计算所有总的残差
    for(uint32_t i = 0; i < nr_instance; ++i)
    {
        Location &location = locations[i]; //第i个样本的信息
        if(location.shrinked)
            continue;

        Meta &meta = metas0[location.tnode_idx - offset]; //找到对应的叶子节点
        meta.s += location.r;//残差之和
        ++meta.n;
    }

    std::vector<Defender> defenders(nr_leaf*nr_field); //记录每一个叶节点的每一维特征
    std::vector<Defender> defenders_sparse(nr_leaf*nr_sparse_field);

    //初始化当前叶子节点每一维的切分点的值
    for(uint32_t f = 0; f < nr_leaf; ++f)
    {
        Meta const &meta = metas0[f];//拿到当前的叶子节点
        double const ese = meta.s*meta.s/static_cast<double>(meta.n);//计算当前叶子节点的ese
        for(uint32_t j = 0; j < nr_field; ++j)
            defenders[f*nr_field+j].ese = ese;
        for(uint32_t j = 0; j < nr_sparse_field; ++j)
            defenders_sparse[f*nr_sparse_field+j].ese = ese;
    }
    std::vector<Defender> defenders_inv = defenders;

    std::thread thread_f(scan, std::ref(prob), std::ref(locations),
        std::ref(metas0), std::ref(defenders), offset, true);//从正方向开始判断
    std::thread thread_b(scan, std::ref(prob), std::ref(locations),
        std::ref(metas0), std::ref(defenders_inv), offset, false);//从负方向开始判断
    scan_sparse(prob, locations, metas0, defenders_sparse, offset, true);
    thread_f.join();
    thread_b.join();
    // 找出最佳的ese,scan里是每个字段的最佳ese,这里是所有字段的最佳ese,赋值给相应的tnode
    for(uint32_t f = 0; f < nr_leaf; ++f)
    {
        // 对于每一个叶节点都找到最好的划分
        Meta const &meta = metas0[f];
        double best_ese = meta.s*meta.s/static_cast<double>(meta.n);
        TreeNode &tnode = tnodes[f+offset];
        //计算稠密矩阵的最佳切分点
        for(uint32_t j = 0; j < nr_field; ++j)
        {
            Defender defender = defenders[f*nr_field+j];//每一个叶节点都对应着所有的特征
            //计算最好的划分点
            if(defender.ese > best_ese)
            {
                best_ese = defender.ese;
                tnode.feature = j;
                tnode.threshold = defender.threshold;
            }

            defender = defenders_inv[f*nr_field+j];
            if(defender.ese > best_ese)
            {
                best_ese = defender.ese;
                tnode.feature = j;
                tnode.threshold = defender.threshold;
            }
        }
        //计算稀疏矩阵的最佳切分点
        for(uint32_t j = 0; j < nr_sparse_field; ++j)
        {
            Defender defender = defenders_sparse[f*nr_sparse_field+j];
            if(defender.ese > best_ese)
            {
                best_ese = defender.ese;
                tnode.feature = nr_field + j;
                tnode.threshold = defender.threshold;
            }
        }
    }
    // 把每个instance都分配给树里的一个叶节点下
    #pragma omp parallel for schedule(static)
    for(uint32_t i = 0; i < nr_instance; ++i)
    {
        Location &location = locations[i];
        if(location.shrinked)
            continue;

        uint32_t &tnode_idx = location.tnode_idx;
        TreeNode &tnode = tnodes[tnode_idx];
        if(tnode.feature == -1)
        {
            location.shrinked = true;
        }
        else if(static_cast<uint32_t>(tnode.feature) < nr_field) //划分的特征是不是稠密矩阵的特征
        {
            if(prob.Z[tnode.feature][i].v < tnode.threshold)
                tnode_idx = 2*tnode_idx; //分配到左节点
            else
                tnode_idx = 2*tnode_idx+1; 
        }
        else
        {
            //划分的特征是稀疏矩阵的特征
            uint32_t const target_feature 
                = static_cast<uint32_t>(tnode.feature-nr_field);
            bool is_one = false;
            for(uint64_t p = prob.SJP[i]; p < prob.SJP[i+1]; ++p) 
            {
                if(prob.SJ[p] == target_feature)
                {
                    is_one = true;
                    break;
                }
            }
            if(!is_one)
                tnode_idx = 2*tnode_idx; 
            else
                tnode_idx = 2*tnode_idx+1; 
        }
    }
}
//以上代码为训练出了CART树
// 用于计算gamma
std::vector<std::pair<double, double>> 
    tmp(max_tnodes, std::make_pair(0, 0));
for(uint32_t i = 0; i < nr_instance; ++i)
{
    float const r = locations[i].r;
    uint32_t const tnode_idx = locations[i].tnode_idx;//当前样本所在的节点的索引
    tmp[tnode_idx].first += r;
    tmp[tnode_idx].second += fabs(r)*(1-fabs(r));
}

for(uint32_t tnode_idx = 1; tnode_idx <= max_tnodes; ++tnode_idx)
{
    double a, b;
    std::tie(a, b) = tmp[tnode_idx-1];
    tnodes[tnode_idx-1].gamma = (b <= 1e-12)? 0 : static_cast<float>(a/b);
}

#pragma omp parallel for schedule(static)
for(uint32_t i = 0; i < nr_instance; ++i)
    F1[i] = tnodes[locations[i].tnode_idx].gamma;// 重新更新F1的值

}
参考博客:http://blog.csdn.net/google19890102/article/details/51746402

  • 8
    点赞
  • 1
    评论
  • 20
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

打赏
文章很值,打赏犒劳作者一下
相关推荐
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页

打赏

horizonheart

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值