GBDT算法整理

最近重點學習了gbdt算法,看了較多的博客文章,整理了一下這些比較有用的內容,包括算法理論、算法分析、代碼剖析、注意事項等各個方面。

轉載來源:

http://www.cnblogs.com/rocketfan/p/4324605.html

http://www.cnblogs.com/rocketfan/p/4365950.html

http://www.cnblogs.com/rocketfan/p/4366501.html

http://blog.163.com/zhoulili1987619@126/blog/static/3530820120159852041442/

http://blog.csdn.net/w28971023/article/details/43704775

http://www.cnblogs.com/leftnoteasy/archive/2011/03/07/random-forest-and-gbdt.html

GBDT的基本原理

這裏以二元分類爲例子,給出最基本原理的解釋

   

GBDT 是多棵樹的輸出預測值的累加

GBDT的樹都是 迴歸樹 而不是分類樹

   

  1. 分類樹

   

分裂的時候選取使得誤差下降最多的分裂

計算的技巧

最終分裂收益按照下面的方式計算,注意圓圈內的部分是固定值

  1. GBDT 二分類

GBDT在實現中可以完全複用上面的計算方法框架,只是我們的優化的目標函數不同。

這裏使用的是 指數誤差函數,不管是預測正確還是錯誤 誤差值都存在,但是正確的預測 會使得誤差值小於錯誤的預測 參考

AdaBoost and the Super Bowl of Classiers

A Tutorial Introduction to Adaptive Boosting

關於常用誤差函數 參考 http://www.cnblogs.com/rocketfan/p/4083821.html

   

參考 Greedy Functon Approximation:A Gradient Boosting Machine

4.4節關於二分類情況誤差函數的設計

這裏其實和上面給出的一樣,只是增加了 log(1 +, 另外多了一個2,2yF), 參考前面的LossFunction http://www.cnblogs.com/rocketfan/p/4083821.html

的推導,其實這個應該算作LogLoss或者說是logistic regression, cross entropy error,也就是從probablity出發的logloss推導到output F(x)的表示就是上面的

式子,而它看上去剛好就是一個指數誤差函數。

嚴格意義上說是LogLoss不是指數誤差 不過LogLoss和指數誤差看上去比較相似。

   

這個F值其實就是邏輯迴歸的思路,類似 語音語言處理一書27頁解釋,線性加權的值(output)用來預測 p(true)和p(false)的比例的log值(迴歸值是實數範圍取值不適合預測0-1,做了一個轉換),越是接近true,那麼F(x)越接近+無窮(對應最大可能性判斷true), p(false)越大 那麼越接近-無窮(對應最大可能性判斷false)

   

F(X) 對應 feature X 當前的迴歸預測值也就是多棵樹經過決策到達葉子節點的輸出值output(x)的累加值。N個樣本則F(x)N個維度,當開始沒有分裂的時候所有樣本在一個節點則所有F(x)對應一個相同的值,分裂一次後兩個葉子節點則F(X)對應可能到不同的葉子節點從而可能有兩個不同的值。

對誤差函數計算關於F的梯度,誤差函數是

變量是F(x)

   

考慮learning_rate之後是 (@TODO)

F(X) 對應 葉子節點中一個樣本對應它的feature X 當前的預測值

參考 機器學習概率角度 一書的16

   

   

我們的分裂目標從上面迴歸樹基本算法中的希望逼近變成了 逼近梯度值 r_im

也就是說當前樹是預測負梯度值的。

F_m(x) = F_m-1(x) + learning_rate*(當前樹的預測值(也就是預測負梯度..)) //@TODO check

   

再對比下ng課件最簡單的梯度下降 針對regression的例子

   

我們採用的每顆樹更新策略是針對F(x)的,而F(x)沿着梯度的方向的累加,目標是使得我們的

誤差函數達到最小。


GBDT原理實例演示 1

考慮一個簡單的例子來演示GBDT算法原理

下面是一個二分類問題,1表示可以考慮的相親對象,0表示不考慮的相親對象

特徵維度有3個維度,分別對象 身高,金錢,顏值

   

cat dating.txt

#id,label,hight,money,face

_0,1,20,80,100

_1,1,60,90,25

_2,1,3,95,95

_3,1,66,95,60

_4,0,30,95,25

_5,0,20,12,55

_6,0,15,14,99

_7,0,10,99,2

   

這個例子僅僅爲了試驗,數據量很小沒有更多統計意義。

   

0,1,2,3對應可以考慮的相親對象

4,5,6,7 對應不考慮的相親對象

   

先看一下gbdt訓練的結果

mlt dating.txt -cl gbdt -ntree 2 -nl 3 -lr 0.1 -mil 1 -c train -vl 1 -mjson=1

設置2棵樹,葉子節點最多3 也就是最多2次分裂,learning rate設置爲0.1 葉子節點中的最少樣本數設置爲1(僅供試驗,一般不會設置爲1,避免過擬合)

爲了打印二叉樹設置輸出json格式的模型

   

Per feature gain:

0:face 1

1:hight 0.730992

2:money 0.706716

Sigmoid/PlattCalibrator calibrating [ 8 ] (0.00011 s)100% |******************************************|

I0324 16:57:53.240083 17630 time_util.h:113] Train! finished using: [1.486 ms] (0.001486 s)

I0324 16:57:53.240094 17630 time_util.h:102] Test itself! started

   

TEST POSITIVE RATIO:        0.5000 (4/(4+4))

   

Confusion table:

||===============================||

|| PREDICTED ||

TRUTH || positive | negative || RECALL

||===============================||

positive|| 4 | 0 || 1.0000 (4/4)

negative|| 0 | 4 || 1.0000 (4/4)

||===============================||

PRECISION 1.0000 (4/4) 1.0000(4/4)

LOG LOSS/instance:                0.2981

TEST-SET ENTROPY (prior LL/in):        1.0000

LOG-LOSS REDUCTION (RIG):        70.1854%

   

OVERALL 0/1 ACCURACY:        1.0000 (8/8)

POS.PRECISION:                1.0000

PPOS.RECALL:                1.0000

NEG.PRECISION:                1.0000

NEG.RECALL:                1.0000

F1.SCORE:                 1.0000

AUC: [1.0000]

   

對應這個例子,訓練結果是perfect的,全部正確, 特徵權重可以看出,對應這個例子訓練結果顏值的重要度最大,看一下訓練得到的樹。

print-gbdt-tree.py --tree -1

Tree 0:

Tree 1:


GBDT原理實例演示 2

一開始我們設定F(x)也就是每個樣本的預測值是0(也可以做一定的隨機化)

Scores = { 0, 0, 0, 0, 0, 0, 0, 0}

   

那麼我們先計算當前情況下的梯度值

   

GetGradientInOneQuery = [this](int query, const Fvec& scores)

{

//和實際代碼稍有出入 簡化版本

_gradient[query] = ((2.0 * label) * sigmoidParam) / (1.0 + std::exp(((2.0 * label) * sigmoidParam) * scores[query]));

};

   

考慮 0號樣本 label1 , learningRate也就是sigmoidParam設置爲0.1, scores[query] = 0 當前Scores全是0

2 * 1 * 0.1 / (1 + exp(2 * 1 * 0.1 * 0)) = 0.1

考慮 7號樣本 label是-1

* -1 * 0.1 / (1 + exp(2 * -1 * 0.1 * 0)) = -0.1

   

因此當前計算的梯度值是

Gradient = { 0.1, 0.1, 0.1, 0.1, -0.1, -0.1, -0.1, -0.1}

   

於是我們要當前樹的輸出F(x)擬合的targets就是這個Grandient

Targets = { 0.1, 0.1, 0.1, 0.1, -0.1, -0.1, -0.1, -0.1}

RegressionTree tree = TreeLearner->FitTargets(activeFeatures, AdjustTargetsAndSetWeights());

   

virtual RegressionTree FitTargets(BitArray& activeFeatures, Fvec& targets) override

   

現在我們考慮擬合這個梯度

gdb ./test_fastrank_train

(gdb) r -in dating.txt -cl gbdt -ntree 2 -nl 3 -lr 0.1 -mil 1 -c train -vl 1 -mjson=1

p Partitioning

$3 = {_documents = std::vector of length 8, capacity 8 = {0, 1, 2, 3, 4, 5, 6, 7}, _initialDocuments = std::vector of length 0, capacity 0, _leafBegin = std::vector of length 3, capacity 3 = {0, 0,

0}, _leafCount = std::vector of length 3, capacity 3 = {8, 0, 0}, _tempDocuments = std::vector of length 0, capacity 0}

   

gbdt對應每個特徵要做離散化分桶處理,比如分255個桶,這裏樣本數據比較少,對應height特徵,

20, 60, 3, 66, 30, 20, 15, 10

分桶也就是變成

BinMedians = std::vector of length 7, capacity 7 = {3, 10, 15, 20, 30, 60, 66}

p *Feature

$11 = {_vptr.Feature = 0xce8650 <vtable for gezi::Feature+16>, Name = "hight",

BinUpperBounds = std::vector of length 7, capacity 7 = {6.5, 12.5, 17.5, 25, 45, 63, 1.7976931348623157e+308},

BinMedians = std::vector of length 7, capacity 7 = {3, 10, 15, 20, 30, 60, 66},

Bins = {_vptr.TVector = 0xce8670 <vtable for gezi::TVector<int>+16>, indices = std::vector of length 0, capacity 0,

values = std::vector of length 8, capacity 8 = {3, 5, 0, 6, 4, 3, 2, 1}, sparsityRatio = 0.29999999999999999, keepDense = false, keepSparse = false, normalized = false, numNonZeros = 7,

length = 8, _zeroValue = 0}, Trust = 1}

 

Bins對應分桶的結果,比如_0樣本hight 20,那麼分桶結果是編號3的桶(0開始index)

   

考慮Root節點的分裂,分裂前考慮是8個樣本在一個節點,我們選取一個最佳的特徵,以及對應該特徵最佳的分裂點

考慮hight特徵,我們要掃描所有可能的分裂點 這裏也就是說 考慮6個不同的分裂點

for (int t = 0; t < (histogram.NumFeatureValues - 1); t += 1)

   

比如6.5這個分裂點

那麼

就是左子樹 1(_2樣本) 右子樹7個,考慮下面公式 收益是 0.1^2/1 + (-0.1)^2/7 - CONSTANT = 0.01142857142857143 - CONSTANT

   

類似的考慮分裂點12.5,17.5……….. 選取一個最佳分裂點

   

然後同樣的考慮 money, face 特徵 選取最優(特徵,分裂點)組合,

這裏最優組合是(hight, 45)

   

左側得到

   

_0,_2,_4,_5,_6, _7 -> 0.1 + 0.1 - 0.1 - 0.1 - 0.1 -0.1

   

右側得到

_1,_3 -> 0.1 + 0.1

收益是

(-0.2)^2 /6 + (0.2)^2 / 2 - CONSTANT = 0.026666666666666665 - CONSTANT

(gdb) p bestShiftedGain

$22 = 0.026666666666666675

   

對應>的子樹輸出應該是 0.2 / 2 = 0.1 下圖對應展示output1,因爲後續還有AdjustOutput,因爲至少需要 F_m(x) = F_m-1(x) + learning_rate*(當前樹的預測值(也就是預測負梯度..)) 黃色部分是最終該棵樹的輸出值

   

   

之後再選取兩個分裂後的組 選一個最佳(特徵,分裂)組合 -> (face, 57.5)

   

(gdb) p tree

$26 = {<gezi::OnlineRegressionTree> = {NumLeaves = 3, _gainPValue = std::vector of length 2, capacity 2 = {0.15304198078836101, 0.27523360741160119},

_lteChild = std::vector of length 2, capacity 2 = {1, -1}, _gtChild = std::vector of length 2, capacity 2 = {-2, -3}, _leafValue = std::vector of length 3, capacity 3 = {-0.10000000000000002,

0.10000000000000002, 0.033333333333333347}, _threshold = std::vector of length 2, capacity 2 = {4, 2}, _splitFeature = std::vector of length 2, capacity 2 = {0, 2},

_splitGain = std::vector of length 2, capacity 2 = {0.026666666666666675, 0.026666666666666679}, _maxOutput = 0.10000000000000002, _previousLeafValue = std::vector of length 2, capacity 2 = {0,

-0.033333333333333333}, _weight = 1, _featureNames = 0x6e6a5a <gezi::FastRank::GetActiveFeatures(std::vector<bool, std::allocator<bool> >&)+34>},

_parent = std::vector of length 3, capacity 3 = {1, -1, -2}}

   

調整一下Output

//GradientDecent.h

virtual RegressionTree& TrainingIteration(BitArray& activeFeatures) override

{

RegressionTree tree = TreeLearner->FitTargets(activeFeatures, AdjustTargetsAndSetWeights());

if (AdjustTreeOutputsOverride == nullptr)

{ //如果父類ObjectiveFunction裏面沒有虛函數 不能使用dynamic_pointer_cast... @TODO

(dynamic_pointer_cast<IStepSearch>(ObjectiveFunction))->AdjustTreeOutputs(tree, TreeLearner->Partitioning, *TrainingScores);

}

{

UpdateAllScores(tree);

}

Ensemble.AddTree(tree);

return Ensemble.Tree();

}

   

virtual void AdjustTreeOutputs(RegressionTree& tree, DocumentPartitioning& partitioning, ScoreTracker& trainingScores) override

{

//AutoTimer timer("dynamic_pointer_cast<IStepSearch>(ObjectiveFunction))->AdjustTreeOutputs");

for (int l = 0; l < tree.NumLeaves; l++)

{

Float output = 0.0;

if (_bestStepRankingRegressionTrees)

{

 * tree.GetOutput(l);

}

else

{ //現在走這裏

 * (tree.GetOutput(l) + 1.4E-45)) / (partitioning.Mean(_weights, Dataset.SampleWeights, l, false) + 1.4E-45);

}

if (output > _maxTreeOutput)

{

output = _maxTreeOutput;

}

else if (output < -_maxTreeOutput)

{

output = -_maxTreeOutput;

}

tree.SetOutput(l, output);

}

}

   

(gdb) p _weights

$33 = std::vector of length 8, capacity 8 = {0.010000000000000002, 0.010000000000000002, 0.010000000000000002, 0.010000000000000002, 0.010000000000000002, 0.010000000000000002,

0.010000000000000002, 0.010000000000000002}

   

_learningRate * tree.Getoutput(1) / partioning.Mean(_weights..) = 0.1 * 0.1 / 0.01 = 1

   

(gdb) p tree

$35 = (gezi::RegressionTree &) @0x7fffffffd480: {<gezi::OnlineRegressionTree> = {

NumLeaves = 3, _gainPValue = std::vector of length 2, capacity 2 = {0.15304198078836101, 0.27523360741160119},

_lteChild = std::vector of length 2, capacity 2 = {1, -1}, _gtChild = std::vector of length 2, capacity 2 = {-2, -3}, _leafValue = std::vector of length 3, capacity 3 = {-1, 1,

0.33333333333333343}, _threshold = std::vector of length 2, capacity 2 = {4, 2}, _splitFeature = std::vector of length 2, capacity 2 = {0, 2},

_splitGain = std::vector of length 2, capacity 2 = {0.026666666666666675, 0.026666666666666679}, _maxOutput = 0.10000000000000002, _previousLeafValue = std::vector of length 2, capacity 2 = {0, -0.033333333333333333}, _weight = 1, _featureNames = 0x6e6a5a <gezi::FastRank::GetActiveFeatures(std::vector<bool, std::allocator<bool> >&)+34>}, _parent = std::vector of length 3, capacity 3 = {1, -1, -2}}

   

之後UpdateAllScores(tree); 是用來更新scores的值,這裏就是8個樣本對應的scores值,也就是計算F(x),注意多棵樹則是對應記錄多棵樹的輸出的值累加

virtual void AddScores(RegressionTree& tree, DocumentPartitioning& partitioning, Float multiplier = 1)

{

for (int l = 0; l < tree.NumLeaves; l++)

{

int begin;

int count;

ivec& documents = partitioning.ReferenceLeafDocuments(l, begin, count);

Float output = tree.LeafValue(l) * multiplier;

int end = begin + count;

#pragma omp parallel for

for (int i = begin; i < end; i++)

{

Scores[documents[i]] += output;

}

SendScoresUpdatedMessage();

}

   

對應第一個棵樹生成結束後

   

(gdb) p Scores

$7 = std::vector of length 8, capacity 8 = {0.33333333333333343, 1, 0.33333333333333343, 1, -1, -1, 0.33333333333333343, -1}

   

這個時候再對應計算梯度:

for (int query = 0; query < Dataset.NumDocs; query++)

{

GetGradientInOneQuery(query, scores);

}

   

_gradient[0] =

2 * 1 * 0.1 / (1 + exp(2 * 1 * 0.1 * 0.33333333333333343))

: 0.2/(1.0 + math.exp(2*0.1/3))

Out[2]: 0.09666790068611772

   

這時候 我們需要擬合的梯度變爲

(gdb) p _gradient

$9 = std::vector of length 8, capacity 8 = {0.096667900686117719, 0.090033200537504438,

0.096667900686117719, 0.090033200537504438, -0.090033200537504438, -0.090033200537504438,

-0.10333209931388229, -0.090033200537504438}

   

   

第二棵樹

p tree

$10 = {<gezi::OnlineRegressionTree> = {NumLeaves = 3,

_gainPValue = std::vector of length 2, capacity 2 = {0.13944890100441296,

0.02357537149418417}, _lteChild = std::vector of length 2, capacity 2 = {-1, -2},

_gtChild = std::vector of length 2, capacity 2 = {1, -3},

_leafValue = std::vector of length 3, capacity 3 = {-0.9721949587186075,

-0.30312179217966367, 0.94840573799486361},

_threshold = std::vector of length 2, capacity 2 = {1, 1},

_splitFeature = std::vector of length 2, capacity 2 = {1, 2},

_splitGain = std::vector of length 2, capacity 2 = {0.024924858166579064,

0.023238200798742146}, _maxOutput = 0.094456333969913306,

_previousLeafValue = std::vector of length 2, capacity 2 = {0, 0.032222633562039242},

_weight = 1,

_featureNames = 0x6e6a5a <gezi::FastRank::GetActiveFeatures(std::vector<bool, std::allocator<bool> >&)+34>}, _parent = std::vector of length 3, capacity 3 = {0, 1, -2}}

   

累加第二棵樹後的Scores,如果有第三棵樹,那麼在這個Scores的基礎上再計算梯度值

(gdb) p Scores

$11 = std::vector of length 8, capacity 8 = {1.2817390713281971, 0.69687820782033638,

1.2817390713281971, 1.9484057379948636, -1.3031217921796636, -1.9721949587186076,

-0.63886162538527413, -1.3031217921796636}

GBDT(MART) 迭代決策樹入門及源碼解析 

GBDT(Gradient Boosting Decision Tree) 又叫 MART(Multiple Additive Regression Tree),是一種迭代的決策樹算法,該算法由多棵決策樹組成,所有樹的結論累加起來做最終答案。它在被提出之初就和SVM一起被認爲是泛化能力(generalization)較強的算法。近些年更因爲被用於搜索排序的機器學習模型而引起大家關注。


GBDT主要由三個概念組成:Regression Decistion Tree(即DT),Gradient Boosting(即GB),Shrinkage (算法的一個重要演進分枝,目前大部分源碼都按該版本實現)。搞定這三個概念後就能明白GBDT是如何工作的,要繼續理解它如何用於搜索排序則需要額外理解RankNet概念,之後便功德圓滿。下文將逐個碎片介紹,最終把整張圖拼出來。

一、 DT:迴歸樹 Regression Decision Tree

提起決策樹(DT, Decision Tree) 絕大部分人首先想到的就是C4.5分類決策樹。但如果一開始就把GBDT中的樹想成分類樹,那就是一條歪路走到黑,一路各種坑,最終摔得都要咯血了還是一頭霧水說的就是LZ自己啊有木有。咳嗯,所以說千萬不要以爲GBDT是很多棵分類樹。決策樹分爲兩大類,迴歸樹和分類樹。前者用於預測實數值,如明天的溫度、用戶的年齡、網頁的相關程度;後者用於分類標籤值,如晴天/陰天/霧/雨、用戶性別、網頁是否是垃圾頁面。這裏要強調的是,前者的結果加減是有意義的,如10歲+5歲-3歲=12歲,後者則無意義,如男+男+女=到底是男是女? GBDT的核心在於累加所有樹的結果作爲最終結果,就像前面對年齡的累加(-3是加負3),而分類樹的結果顯然是沒辦法累加的,所以GBDT中的樹都是迴歸樹,不是分類樹,這點對理解GBDT相當重要(儘管GBDT調整後也可用於分類但不代表GBDT的樹是分類樹)。那麼迴歸樹是如何工作的呢?

下面我們以對人的性別判別/年齡預測爲例來說明,每個instance都是一個我們已知性別/年齡的人,而feature則包括這個人上網的時長、上網的時段、網購所花的金額等。

作爲對比,先說分類樹,我們知道C4.5分類樹在每次分枝時,是窮舉每一個feature的每一個閾值,找到使得按照feature<=閾值,和feature>閾值分成的兩個分枝的熵最大的feature和閾值(熵最大的概念可理解成儘可能每個分枝的男女比例都遠離1:1),按照該標準分枝得到兩個新節點,用同樣方法繼續分枝直到所有人都被分入性別唯一的葉子節點,或達到預設的終止條件,若最終葉子節點中的性別不唯一,則以多數人的性別作爲該葉子節點的性別。

迴歸樹總體流程也是類似,不過在每個節點(不一定是葉子節點)都會得一個預測值,以年齡爲例,該預測值等於屬於這個節點的所有人年齡的平均值。分枝時窮舉每一個feature的每個閾值找最好的分割點,但衡量最好的標準不再是最大熵,而是最小化均方差--即(每個人的年齡-預測年齡)^2 的總和 / N,或者說是每個人的預測誤差平方和 除以 N。這很好理解,被預測出錯的人數越多,錯的越離譜,均方差就越大,通過最小化均方差能夠找到最靠譜的分枝依據。分枝直到每個葉子節點上人的年齡都唯一(這太難了)或者達到預設的終止條件(如葉子個數上限),若最終葉子節點上人的年齡不唯一,則以該節點上所有人的平均年齡做爲該葉子節點的預測年齡。若還不明白可以Google "Regression Tree"。

二、 GB:梯度迭代 Gradient Boosting

好吧,我起了一個很大的標題,但事實上我並不想多講Gradient Boosting的原理,因爲不明白原理並無礙於理解GBDT中的Gradient Boosting。喜歡打破砂鍋問到底的同學可以閱讀這篇英文wikihttp://en.wikipedia.org/wiki/Gradient_boosted_trees#Gradient_tree_boosting

Boosting,迭代,即通過迭代多棵樹來共同決策。這怎麼實現呢?難道是每棵樹獨立訓練一遍,比如A這個人,第一棵樹認爲是10歲,第二棵樹認爲是0歲,第三棵樹認爲是20歲,我們就取平均值10歲做最終結論?--當然不是!且不說這是投票方法並不是GBDT,只要訓練集不變,獨立訓練三次的三棵樹必定完全相同,這樣做完全沒有意義。之前說過,GBDT是把所有樹的結論累加起來做最終結論的,所以可以想到每棵樹的結論並不是年齡本身,而是年齡的一個累加量。GBDT的核心就在於,每一棵樹學的是之前所有樹結論和的殘差,這個殘差就是一個加預測值後能得真實值的累加量。比如A的真實年齡是18歲,但第一棵樹的預測年齡是12歲,差了6歲,即殘差爲6歲。那麼在第二棵樹裏我們把A的年齡設爲6歲去學習,如果第二棵樹真的能把A分到6歲的葉子節點,那累加兩棵樹的結論就是A的真實年齡;如果第二棵樹的結論是5歲,則A仍然存在1歲的殘差,第三棵樹裏A的年齡就變成1歲,繼續學。這就是Gradient Boosting在GBDT中的意義,簡單吧。

三、 GBDT工作過程實例。

還是年齡預測,簡單起見訓練集只有4個人,A,B,C,D,他們的年齡分別是14,16,24,26。其中A、B分別是高一和高三學生;C,D分別是應屆畢業生和工作兩年的員工。如果是用一棵傳統的迴歸決策樹來訓練,會得到如下圖1所示結果:


現在我們使用GBDT來做這件事,由於數據太少,我們限定葉子節點做多有兩個,即每棵樹都只有一個分枝,並且限定只學兩棵樹。我們會得到如下圖2所示結果:


在第一棵樹分枝和圖1一樣,由於A,B年齡較爲相近,C,D年齡較爲相近,他們被分爲兩撥,每撥用平均年齡作爲預測值。此時計算殘差(殘差的意思就是: A的預測值 + A的殘差 = A的實際值),所以A的殘差就是16-15=1(注意,A的預測值是指前面所有樹累加的和,這裏前面只有一棵樹所以直接是15,如果還有樹則需要都累加起來作爲A的預測值)。進而得到A,B,C,D的殘差分別爲-1,1,-1,1。然後我們拿殘差替代A,B,C,D的原值,到第二棵樹去學習,如果我們的預測值和它們的殘差相等,則只需把第二棵樹的結論累加到第一棵樹上就能得到真實年齡了。這裏的數據顯然是我可以做的,第二棵樹只有兩個值1和-1,直接分成兩個節點。此時所有人的殘差都是0,即每個人都得到了真實的預測值。

換句話說,現在A,B,C,D的預測值都和真實年齡一致了。Perfect!:

A: 14歲高一學生,購物較少,經常問學長問題;預測年齡A = 15 – 1 = 14

B: 16歲高三學生;購物較少,經常被學弟問問題;預測年齡B = 15 + 1 = 16

C: 24歲應屆畢業生;購物較多,經常問師兄問題;預測年齡C = 25 – 1 = 24

D: 26歲工作兩年員工;購物較多,經常被師弟問問題;預測年齡D = 25 + 1 = 26

那麼哪裏體現了Gradient呢?其實回到第一棵樹結束時想一想,無論此時的cost function是什麼,是均方差還是均差,只要它以誤差作爲衡量標準,殘差向量(-1, 1, -1, 1)都是它的全局最優方向,這就是Gradient。

講到這裏我們已經把GBDT最核心的概念、運算過程講完了!沒錯就是這麼簡單。不過講到這裏很容易發現三個問題:

1)既然圖1和圖2 最終效果相同,爲何還需要GBDT呢?

答案是過擬合。過擬合是指爲了讓訓練集精度更高,學到了很多”僅在訓練集上成立的規律“,導致換一個數據集當前規律就不適用了。其實只要允許一棵樹的葉子節點足夠多,訓練集總是能訓練到100%準確率的(大不了最後一個葉子上只有一個instance)。在訓練精度和實際精度(或測試精度)之間,後者纔是我們想要真正得到的。


我們發現圖1爲了達到100%精度使用了3個feature(上網時長、時段、網購金額),其中分枝“上網時長>1.1h” 很顯然已經過擬合了,這個數據集上A,B也許恰好A每天上網1.09h, B上網1.05小時,但用上網時間是不是>1.1小時來判斷所有人的年齡很顯然是有悖常識的;


相對來說圖2的boosting雖然用了兩棵樹 ,但其實只用了2個feature就搞定了,後一個feature是問答比例,顯然圖2的依據更靠譜。(當然,這裏是LZ故意做的數據,所以才能靠譜得如此狗血。實際中靠譜不靠譜總是相對的) Boosting的最大好處在於,每一步的殘差計算其實變相地增大了分錯instance的權重,而已經分對的instance則都趨向於0。這樣後面的樹就能越來越專注那些前面被分錯的instance。就像我們做互聯網,總是先解決60%用戶的需求湊合着,再解決35%用戶的需求,最後才關注那5%人的需求,這樣就能逐漸把產品做好,因爲不同類型用戶需求可能完全不同,需要分別獨立分析。如果反過來做,或者剛上來就一定要做到盡善盡美,往往最終會竹籃打水一場空。

2)Gradient呢?不是“G”BDT麼?

到目前爲止,我們的確沒有用到求導的Gradient。在當前版本GBDT描述中,的確沒有用到Gradient,該版本用殘差作爲全局最優的絕對方向,並不需要Gradient求解.



3)這不是boosting吧?Adaboost可不是這麼定義的。

這是boosting,但不是Adaboost。GBDT不是Adaboost Decistion Tree。就像提到決策樹大家會想起C4.5,提到boost多數人也會想到Adaboost。Adaboost是另一種boost方法,它按分類對錯,分配不同的weight,計算cost function時使用這些weight,從而讓“錯分的樣本權重越來越大,使它們更被重視”。Bootstrap也有類似思想,它在每一步迭代時不改變模型本身,也不計算殘差,而是從N個instance訓練集中按一定概率重新抽取N個instance出來(單個instance可以被重複sample),對着這N個新的instance再訓練一輪。由於數據集變了迭代模型訓練結果也不一樣,而一個instance被前面分錯的越厲害,它的概率就被設的越高,這樣就能同樣達到逐步關注被分錯的instance,逐步完善的效果。Adaboost的方法被實踐證明是一種很好的防止過擬合的方法,但至於爲什麼則至今沒從理論上被證明。GBDT也可以在使用殘差的同時引入Bootstrap re-sampling,GBDT多數實現版本中也增加的這個選項,但是否一定使用則有不同看法。re-sampling一個缺點是它的隨機性,即同樣的數據集合訓練兩遍結果是不一樣的,也就是模型不可穩定復現,這對評估是很大挑戰,比如很難說一個模型變好是因爲你選用了更好的feature,還是由於這次sample的隨機因素。

四、Shrinkage

Shrinkage(縮減)的思想認爲,每次走一小步逐漸逼近結果的效果,要比每次邁一大步很快逼近結果的方式更容易避免過擬合。即它不完全信任每一個棵殘差樹,它認爲每棵樹只學到了真理的一小部分,累加的時候只累加一小部分,通過多學幾棵樹彌補不足。用方程來看更清晰,即

沒用Shrinkage時:(yi表示第i棵樹上y的預測值, y(1~i)表示前i棵樹y的綜合預測值)

y(i+1) = 殘差(y1~yi), 其中: 殘差(y1~yi) = y真實值 - y(1 ~ i)

y(1 ~ i) = SUM(y1, ..., yi)

Shrinkage不改變第一個方程,只把第二個方程改爲:

y(1 ~ i) = y(1 ~ i-1) + step * yi


即Shrinkage仍然以殘差作爲學習目標,但對於殘差學習出來的結果,只累加一小部分(step*殘差)逐步逼近目標,step一般都比較小,如0.01~0.001(注意該step非gradient的step),導致各個樹的殘差是漸變的而不是陡變的。直覺上這也很好理解,不像直接用殘差一步修復誤差,而是隻修復一點點,其實就是把大步切成了很多小步。本質上,Shrinkage爲每棵樹設置了一個weight,累加時要乘以這個weight,但和Gradient並沒有關係。這個weight就是step。就像Adaboost一樣,Shrinkage能減少過擬合發生也是經驗證明的,目前還沒有看到從理論的證明。


五、 GBDT的適用範圍

該版本GBDT幾乎可用於所有迴歸問題(線性/非線性),相對logistic regression僅能用於線性迴歸,GBDT的適用面非常廣。亦可用於二分類問題(設定閾值,大於閾值爲正例,反之爲負例)。

六、 搜索引擎排序應用 RankNet

搜索排序關注各個doc的順序而不是絕對值,所以需要一個新的cost function,而RankNet基本就是在定義這個cost function,它可以兼容不同的算法(GBDT、神經網絡...)。


實際的搜索排序使用的是LambdaMART算法,必須指出的是由於這裏要使用排序需要的cost function,LambdaMART迭代用的並不是殘差。Lambda在這裏充當替代殘差的計算方法,它使用了一種類似Gradient*步長模擬殘差的方法。


就像所有的機器學習一樣,搜索排序的學習也需要訓練集,這裏一般是用人工標註實現,即對每一個(query,doc) pair給定一個分值(如1,2,3,4),分值越高表示越相關,越應該排到前面。然而這些絕對的分值本身意義不大,例如你很難說1分和2分文檔的相關程度差異是1分和3分文檔差距的一半。相關度本身就是一個很主觀的評判,標註人員無法做到這種定量標註,這種標準也無法制定。但標註人員很容易做到的是”AB都不錯,但文檔A比文檔B更相關,所以A是4分,B是3分“。RankNet就是基於此制定了一個學習誤差衡量方法,即cost function。具體而言,RankNet對任意兩個文檔A,B,通過它們的人工標註分差,用sigmoid函數估計兩者順序和逆序的概率P1。然後同理用機器學習到的分差計算概率P2(sigmoid的好處在於它允許機器學習得到的分值是任意實數值,只要它們的分差和標準分的分差一致,P2就趨近於P1)。這時利用P1和P2求的兩者的交叉熵,該交叉熵就是cost function。它越低說明機器學得的當前排序越趨近於標註排序。爲了體現NDCG的作用(NDCG是搜索排序業界最常用的評判標準),RankNet還在cost function中乘以了NDCG。


好,現在我們有了cost function,而且它是和各個文檔的當前分值yi相關的,那麼雖然我們不知道它的全局最優方向,但可以求導求Gradient,Gradient即每個文檔得分的一個下降方向組成的N維向量,N爲文檔個數(應該說是query-doc pair個數)。這裏僅僅是把”求殘差“的邏輯替換爲”求梯度“,可以這樣想:梯度方向爲每一步最優方向,累加的步數多了,總能走到局部最優點,若該點恰好爲全局最優點,那和用殘差的效果是一樣的。這時套到之前講的邏輯,GDBT就已經可以上了。那麼最終排序怎麼產生呢?很簡單,每個樣本通過Shrinkage累加都會得到一個最終得分,直接按分數從大到小排序就可以了(因爲機器學習產生的是實數域的預測分,極少會出現在人工標註中常見的兩文檔分數相等的情況,幾乎不同考慮同分文檔的排序方式)


另外,如果feature個數太多,每一棵迴歸樹都要耗費大量時間,這時每個分支時可以隨機抽一部分feature來遍歷求最優。



GBDT源碼剖析


如今,GBDT被廣泛運用於互聯網行業,他的原理與優點這裏就不細說了,網上google一大把。但是,我自認爲自己不是一個理論牛人,對GBDT的理論理解之後也做不到從理論舉一反三得到更深入的結果。但是學習一個算法,務必要深入細緻才能領會到這個算法的精髓。因此,在瞭解了足夠的GBDT理論之後,就需要通過去閱讀其源碼來深入學習GBDT了。但是,網上有關這類資料甚少,因此,我不得不自己親自抄刀,索性自己從頭學習了一下GBDT源碼。幸好,這個算法在機器學習領域中的其它算法還是非常簡單的。這裏將心得簡單分享,歡迎指正。回覆本公衆號“GBDT”可下載源碼。


首先,這裏需要介紹一下程序中用到的結構體,具體的每一個結構體的內容這裏就不再贅述了,源碼裏面都有。這裏只再細說一下每個結構體的作用,當然一些重要的結構體會詳細解釋。

struct gbdt_model_t:GBDT模型的結構體,也就是最終我們訓練得到的由很多棵決策樹組成的模型。


typedef struct {
int* nodestatus; //!< 
int* depth; // 
int* splitid; //!< 
double* splitvalue; //!< 
int* ndstart; //!< 節點對應於 Index 的開始位置
int* ndcount; //!< 節點內元素的個數
double* ndavg; //!< 節點內元素的均值 
//double* vpredict;
int* lson; //!< 左子樹
int* rson; //!< 右子樹
int nodesize; //!< 樹的節點個數
}gbdt_tree_t;

struct gbdt_tree_t:


當然就代表模型中的一棵樹的各種信息了。爲了後面能理解,這裏需要詳細解釋一下這個結構體。splitid[k]保存該棵樹的第k個結點分裂的feature下標,splitvalue[k]保存該棵樹第k個結點的分裂值,nodestatus[k]代表該棵樹的第k個結點的狀態,如果爲GBDT_INTERIOR,代表該結點已分裂,如果爲GBDT_TOSPLIT,代表該結點需分裂,如果爲GBDT_TERMINAL表示該結點不需再分裂,一般是由於該結點的樣本數ndcount[k]少於等於一閾值gbdt_min_node_size;depth[ncur+1]代表左子樹的深度,depth[ncur+2]表示右子樹的深度,其中ncur的增長步長爲2,表示每次+2都相關於跳過當前結點的左子樹和右子樹,到達下一個結點。ndstart[ncur+1]代表劃分到左子樹開始樣本的下標,ndstart[ncur+2]代表劃分到右子樹開始樣本的下標,其中到底這個下標是代表第幾個樣本是由index的一個結構保存。ndcount[ncur + 1]代表劃分到左子樹的樣本數量,ndcount[ncur + 2]代表劃分到右子樹的樣本數量。ndavg[ncur+1]代表左子樹樣本的均值,同理是右子樹樣本的均值。nodestatus[ncur+1] = GBDT_TOSPLIT表示左子樹可分裂。lson[k]=ncur+1表示第k個結點的左子樹,同理表示第k個結點的右子樹。


gbdt_info_t保存模型配置參數。

typedef struct 

int* fea_pool; //!< 隨機 feature 候選池
double* fvalue_list; //!< 以feature i 爲拉鍊的特徵值 x_i
double* fv; //!< 特徵值排序用的buffer版本
double* y_list; //!< 迴歸的y值集合
int* order_i; //!< 排序的標號
} bufset; //!< 訓練數據池

bufset代表訓練數據池,它保存了訓練當前一棵樹所用到的一些數據。fea_pool保存了訓練數據的特徵的下標,循環rand_fea_num(feature隨機採樣量)次,隨機地從fea_pool中選取特徵來計算分裂的損失函數(先過的feature不會再選)。fvalue_list保存在當前選擇特徵fid時,所有采樣的樣本特徵fid對應的值。fv與favlue_list一樣。y_list表示採樣樣本的y值。order_i保存左子樹與右子樹結點下標。

nodeinfo代表節點的信息。

typedef struct 

int bestid; //!< 分裂使用的Feature ID
double bestsplit; //!< 分裂邊界的x值
int pivot; //!< 分裂邊界的數據標號 
} splitinfo; //!< 分裂的信息

splitinfo代表分裂的信息。pivot代表分裂點在order_i中的下標。bestsplit表示分裂值。bestid表示分裂的feature。

好了,解釋完關鍵的一些結構體,下面要看懂整個gbdt的流程就非常簡單了。這裏我就簡單的從頭至尾敘述一下整個訓練的流程。


首先申請分配模型空間gbdt_model,並且計算所有樣本在每一維特徵上的平均值。假如我們需要訓練infbox.tree_num棵樹,每一棵的訓練流程爲:從x_fea_value中採樣gbdt_inf.sample_num個樣本,index[i]記錄了第i個結點所對應的樣本集合x_fea_value中的下標,其始終保存了訓練本棵樹的所有采樣樣本對應樣本空間的下標值,同時,結點的順序是按該棵樹所有結點按廣度優先遍歷算法遍歷的結果的。即當前樹gbdt_single_tree只有一個根結點0,其中gbdt_single_tree->nodestatus爲GBDT_TOSPLIT,ndstart[0]=0,ndcount[0]=sample_num,ndavg爲所有采樣樣本的y的梯度值均值。下面就是對這個結點進行分裂的過程:首先nodeinfo ninf這個結構體保存了當前分裂結點的一些信息,比如結點中樣本開始的下標(指相對於index的下標值,index指向的值纔是樣本空間中該樣本的下標),樣本結束下標(同上),樣本結點數,樣本結點的y的梯度之和等。循環rand_fea_num次,隨機採樣feature,來計算在該feature分裂的信息增益,計算方式爲(左子樹樣子目標值和的平方均值+右子樹目標值和的平方均值-父結點所有樣本和的平方均值)。選過的feature就不會再選中來計算信息增益了。利用data_set來保存當前分裂過程所用到的一些信息,包括候選feature池,選中feature對應的採樣樣本的特徵值及其y值。data_set->order_i保存了左右子樹對應結點在樣本集合中的下標。計算每個feature的信息增益,並取最大的,保存分點信息到spinf中,包括最優分裂值,最優分裂feature。然後,將該結點小於分裂值的結點樣本下標與大於分裂值的結點樣本下標都保存在data_set->order_i中,nl記錄了order_i中右子樹開始的位置。更新index數組,將order_i中copy到index中。將nl更新到spinf中。注意index數組從左至右保存了最終分裂的左子樹與右子樹樣本對應在樣本空間的下標。


至此,我們找到了這個結點的最優分裂點。gbdt_single_tree->ndstart[1]保存了左孩子的開始下標(指相對於index的下標值,index指向的值纔是樣本下標),gbdt_single_tree->ndstart[2]保存了右孩子的開始下標,即nl的值。同理,ndcount,depth等也是對就保存了左右孩子信息。gbdt_single_tree->lson[0]=1,gbdt_single_tree->lson[0]=2即表示當前結點0的左子樹是1,右子樹是2。當前結點分裂完了之後,下一次就同理廣度優先算法,對該結點的孩子繼續上述步驟。


該棵樹分裂完成之後,對每一個樣本,都用目前模型(加上分裂完成的這棵樹)計算預測值,並且更新每一個樣本的殘差y_gradient。計算過程:選取當前結點的分裂feature以及分裂值,小於則走左子樹,大於則走右子樹,直到葉子結點。預測值爲shrink*該葉子結點的樣本目標值的均值。


訓練第二棵樹同理,只是訓練的樣本的目標值變成了前面模型預測結果的殘差了。這點就體現在梯度下降的尋優過程。


好了,這裏只是簡單的對gbdt代碼做了說明,當然如果沒有看過本文引用的源碼,是不怎麼能看懂的,如果結合源碼來看,就很容易看懂了。總之,個人感覺,只有結合原碼來學習gbdt,才真正能體會到事個模型的學習以及樹的生成過程。

GBDT理解二三事

一、要理解GBDT當然要從GB(Gradient Boosting)和DT(Decision Tree)兩個角度來理解了;

二、GB其實是一種理念,他並不是這一個具體的算法,意思是說沿着梯度方向,構造一系列的弱分類器函數,並以一定權重組合起來,形成最終決策的強分類器;注意,這裏的梯度下降法是在函數空間中通過梯度下降法尋找使得LOSS最小的一個函數,即L(y,f)對f求層,區別於傳統的梯度下降法選擇一個方向(對x求導);那麼問題就來了,對函數求導?這也太難了吧。所以就有了一個近似的方法,根據經驗風險最小化原則,我們認爲在訓練集上使得LOSS最小的函數,往往在測試集上表現會好,即在訓練集上尋優;因此,把求導的函數理解成在訓練集上該函數對應的離散的函數值,對函數求導就變成了對樣本的函數值向量求導;因此就可以得到一個梯度向量,表示尋找到的最優函數, 這個函數就是一個新的弱分類器;

三、通過迴歸樹來擬合這個梯度向量,就得到了DT,而每棵樹就對應上面的函數,其預測值就是函數值;

四、當我們選擇平方差損失函數時,函數向量就表示成前一棵迴歸樹在樣本空間上的預測值,則對函數向量求梯度就等於目標值減去預測值,即我們所說的殘差向量;因此,下一棵迴歸樹就是在擬合這個殘差向量;

五、迴歸樹擬合可以通過平均最小均方差來尋找分裂點,生成一個樹;當然這棵樹不可能完全擬合得好,因此,又會通過對損失函數求梯度,得到新的殘差向量;

六、對初始分類器(函數)的選擇就可以直接用0,通過平方差LOSS函數求得的殘差當然就是樣本本身了;也可以選擇樣本的均值;

七、一棵樹的分裂過程只需要找到找到每個結點的分裂的特徵id與特徵值,而尋找的方法可以是平均最小均方差,也可以是使得(左子樹樣本目標值和的平方均值+右子樹樣本目標值和的平方均值-父結點所有樣本目標值和的平方均值)最大的那個分裂點與分裂特徵值等等方法;從而將樣本分到左右子樹中,繼續上面過程;

八、用殘差更新每個樣本的目標值:葉子節點的均值作爲落到該葉子節點的樣本的預測值,使用目標值減去預測值,得到該樣本的殘差,作爲下一棵樹的訓練目標;

九、對於使用logistic作爲損失函數的多分類問題,下面單獨進行推導說明:

1、多分類問題與迴歸問題不同,每棵樹的樣本的目標就不是一個數值了,而是每個樣本在每個分類下面都有一個估值Fk(x);

2、同邏輯迴歸一樣,假如有K類,每一個樣本的估計值爲F1(x)...Fk(x),對其作logistic變化之後得到屬於每一類的概率是P1(x)...pk(x),則損失函數可以定義爲負的log似然:

可以看出對多分類問題,新的一棵樹擬合的目標仍是殘差向量;

3、訓練過程如下:


對第一棵樹,可以初始化每個樣本在每個分類上的估計值Fk(x)都爲0;計算logistic變換pk(x),計算殘差向量,作爲當前樹的迴歸的目標,迴歸樹的分裂過程仍可採用【左子樹樣本目標值(殘差)和的平方均值+右子樹樣本目標值(殘差)和的平方均值-父結點所有樣本目標值(殘差)和的平方均值】最大的那個分裂點與分裂特徵值等方法;當迴歸樹的葉子節點數目達到要求示,則該樹建立完成;對每個葉子節點,利用落到該葉子節點的所有樣本的殘差向量,計算增益rjkm;更新每一個樣本的估計值Fk(x);因此,又可以對估計進行logistic變化,利用樣本的目標值計算殘差向量,訓練第二棵樹了;

4、注意樣本的估計值Fk(x)是前面所有樹的估值之和,因此,計算殘差時,用樣本的目標值減去Fk(x)就可以得到殘差了;

十、GBDT並行化:

1、按行並行化,將樣本按行分成N份,分別在N個節點上做計算;

2、並行建立一棵的過程:

1>在0號節點上對特徵隨機採樣,生成建立一棵樹需要用到的特徵,並分發到N個節點上;

2>在0號結點上維護每一維採樣特徵所有可能的特徵值;

3>將每一維特徵的每一個可能的特徵值分發到N個節點上;

4>每一個節點並行計算該節點上所有樣本與分發得到的特徵值的比較結果,分割成左右子樹,並計算增益;

5>歸併所有節點的增益,在0號結點得到每一個特徵在每一個特徵值的增益(f,v,incr);

6>在0號結點上找出最大的(f,v,incr),並作爲本次的最佳裂點,分發到N個節點上;

7>N個節點將樣本分割成左右子樹;

8>對左右子樹繼續上面過程,直到葉子節點數目滿足要求;

3、並行建立第二棵樹;

因此,GBDT並行化包括了樣本並行化與特徵分裂點計算的並行化;其中最耗時的仍然是需要遍歷特徵的所有可能的特徵值,並計算增益尋找最優分裂點的過程;可以採用對特徵值直方圖採樣,不用遍歷所有特徵值來優化。


這裏參考了http://www.cnblogs.com/leftnoteasy/archive/2011/03/07/random-forest-and-gbdt.html對分類問題的解釋,寫得非常好,treelink裏面的代碼基本就是按照這個流程實現的。



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章