LightGBM源码阅读+理论分析(处理特征类别,缺省值的实现细节)
前言
關于LightGBM,網上已經介紹的很多了,筆者也零零散散的看了一些,有些寫的真的很好,但是最終總覺的還是不夠清晰,一些細節還是懵懵懂懂,大多數只是將原論文翻譯了一下,可是某些技術具體是怎么做的呢?即落實到代碼是怎么做的呢?網上資料基本沒有,所以總有一種似懂非懂的感覺,貌似懂了LightGBM,但是又很陌生,很不踏實,所以本篇的最大區別或者優勢是:源碼分析,看看其到底怎么實現的,同時會將源碼中的參數和官網給的API結合,這樣對一些超參數理解會更透徹(對于一些諸如學習率的參數都是以前GBDT同用的,很熟悉了這里就沒源碼介紹,感興趣的自行看源碼),下面理解僅代表個人觀點,若有錯還請大家指教,一起學習交流,同時這里最大的貢獻就是對源碼的大體框架進行了一個摸索,對其中很多細節也歡迎大家交流學習!!!!最后希望本篇能夠給大家在認識LightGBM方面帶來那么一點點幫助!!!共勉!!!
建議:大家在學習LightGBM的時候(學習其他算法一樣),不要僅僅是在網上隨便百度一下,支零破碎的了解,最好一開始還是通讀一遍原論文,其是最權威的,也是網上所有解析LightGBM文章的出處,包括最終LightGBM實際實現都是來源此論文,有部分博客包括本篇博客有可能因為個人理解有偏差,導致解析有誤,如果不看論文容易被誤導,所以還是建議看論文,加上自己的理解和博客介紹可能會更好!!!!!
本篇是按照相關技術來劃分模塊介紹的,以“ -------------”分隔開來,每個技術都以紫色斜體單獨標了出來!!!!
參考:
原論文:https://papers.nips.cc/paper/6907-lightgbm-a-highly-efficient-gradient-boosting-decision-tree.pdf
源碼:GitHub - microsoft/LightGBM: A fast, distributed, high performance gradient boosting (GBT, GBDT, GBRT, GBM or MART) framework based on decision tree algorithms, used for ranking, classification and many other machine learning tasks.
官方API:Parameters — LightGBM 3.2.1.99 documentation
關于其實踐,筆者這里用了一個kaggle上面正在比賽的賽題,有興趣的同學可以看下:
lightgbm實踐:Kaggle桑坦德銀行客戶交易預測比賽baseline_愛吃火鍋的博客-CSDN博客
-------------------------------------------------------------------------------------------------------------------------------------------------------------------
概述:
? ? ? ? ? ? ?GBDT一經提出,就得到了廣泛的關注,其演變的算法多多,包括Scikit-learn,gbm in R和pGBRT等等,尤其Xgboost更是被認為泛化能力最好的算法,其在各種問題中都表現出了優異的性能,但是在使用的過程中可以感受得出來Xgboost模型訓練需要較長的時間,也就是說效率不是很高,能不能在Xgboost的基礎上進一步改進,使得其訓練速度提高呢?有需求就有了努力的方向,正是在這一背景下LightGBM出現了。
? ? ? ? ? ? ?從名字也可以看出是Light+GBM,對于GBM很熟悉了其全稱應該是gradient boosting machine即梯度提升樹算法,其在該方面繼續延續了Xgboost那一套集成學習方法,這里不再重述,Light是輕量級的意思,其關注的是模型訓練速度,其也是LightGBM提出的初衷。所以本文就著眼盯住Light,看看其究竟是怎么做到Light的。
再次強調,LightGBM的著重點就是兩個字? ?“要快!!!!!!!!!”,所以我們在看其算法的時候不論其提出采用了什么新的技術記住其就是一個出發點快!!!本著這個原則我們來學習LightGBM。(所介紹的技術都是和快有關的)
?LightGBM在實際代碼實現的時候采用了多種“快”的技術集合,但就原論文的而言主要提出了兩大技術:
? (1)GOSS(Gradient-based One-Side Sampling):減少樣本數
? (2)EFB (Exclusive Feature Bundling ):減少特征數
可以看到其想法的出發點很簡單也很容易想到,關鍵怎么落實,后面大家會看到。
除此之外其在真真實現的時候還采用了直方圖,支持分布式等等,通過下面的源碼分析會看到一下細節:
直方圖差加速
自動處理缺省值,包括是否將0視為缺省值。
處理類別特征
EFB、GOSS、Leaf-wise等細節
LightGBM正是由于本著快的思想,最終導致的一個優點就是能夠處理大數據!!!!!!!
? ?---------------------------------------------------------------------------------------------------------------------------------------------------------------------
直方圖 FeatureHistogram
在訓練樹的時候,需要找到最佳劃分節點,為此其中需要遍歷特征下的每一個value,這里通常有兩種做法:pre-sorted algorithm(預排序算法)和histogram-based algorithm(直方圖算法)。
預排序算法就是傳統的要遍歷當前特征下的每一個value,其通常是在開始對該特征下的每個value進行排序,后面就是遍歷選取最佳劃分點,直方圖算法其實就是將value離散化了,生成一個個bin,常稱為分桶,離散化后的bin數其實是小于原始的value數的,于是復雜度從(#feature*#data)降為了(#feature*#bin)。
需要注意:直方圖算法并不是LightGBM所特有的或是閃亮點(其閃亮點還是論文所說的兩大技術GOSS和EFB),GBDT的相關演變算法有很多,有部分計算法就用了直方圖,類如Scikit-learn和gbm in R演變算法使用了pre-sorted algorithm算法,pGBRT算法是使用了histogram-based algorithm,而XGBoost兩者都支持,這里多說一句,Xgboost的近似算法其實就用了histogram-based algorithm如下:
(上面所說的幾種演變算法,在論文的參考文獻都可以找到,有興趣的可以拜讀一下)
對于特征k這里找到先找到一組候選分割點就是上圖中的Sk1,Sk2,Sk2,這里和LightGBM中所提到的bin其實思想是一樣的
不同于pre-sorted algorithm的窮舉方法,這里有兩種算法一種是全局算法:即在初始化tree的時候劃分好候選分割點,并且在樹的每一層都使用這些候選分割點;另一種是局部算法,即每一次劃分的時候都重新計算候選分割點。這兩者各有利弊,全局算法不需要多次計算候選節點,但需要一次獲取較多的候選節點供后續樹生長使用,而局部算法一次獲取的候選節點較少,可以在分支過程中不斷改善,即適用于生長更深的樹,兩者在effect和accuracy做trade off。
關于怎么找這些候選集合,其使用了Weighted Quantile Sketch算法,有興趣的同學可以看一下:Xgboost近似分位數算法_anshuai_aw1的博客-CSDN博客
總之這里想表達一句話就是:因為在以前GBDT的眾多演變算法中,Xgbosst性能應該是最好的一個啦(Xgboost簡單介紹https://blog.csdn.net/weixin_42001089/article/details/84965333),而LightGBM也算是演變家族中的一員,所以為了凸顯其優越性,都是直接和Xgboost對比的,論文第五部分給出了對比結果大家可以直接去看,但是須知單從和Xgboost對比角度來看并不是直方圖帶來的這種差異可觀的效果或者說很大方面不是,其是下面介紹的多種其他方面技術結合的結果,尤其是其論文提到的兩大技術,當然了單單看直方圖算法,這不是也是一種優化,所以LightGBM本著快的原則也采用了histogram-based algorithm
看到Xgboost,其近似算法和LightGBM算法的速度對比
好了說了這么多理論下面看一下其源碼吧:
位于LightGBM/src/treelearner/feature_histogram.hpp
其下一共有三個類:
FeatureMetainfo
FeatureHistogram
HistogramPool
其中?FeatureMetainfo類是直方圖分裂算法的一些基本配置,其只有屬性沒有方法。
FeatureHistogram可以說是直方圖分裂算法的核心部分,我們主要來看看該類和分裂相關的幾個主要方法,其屬性很簡單就是data_即直方圖中的儲存數據,包括一階導數總和以及二階導數總和等等。
HistogramPool類的作用就是構建data_的過程。
明白了大致的作用,接下來我們就深入其中一探究竟:
?FeatureMetainfo屬性:
類的屬性如下:
public:int num_bin;MissingType missing_type;int8_t bias = 0;uint32_t default_bin;int8_t monotone_type;double penalty;/*! \brief pointer of tree config */const Config* config;BinType bin_type; };其中config中包含了很多與樹有關的屬性,在下面會看到,這里還有一個比較重要的屬性是missing_type,其是lightGBM的一個超參數,用于指明缺省值類型后面會看到解釋
接下來就是FeatureHistogram這個重點啦,
其屬性較為簡單:
FeatureHistogram() {data_ = nullptr;}接下來我們重點看看該類定義的方法
(1)ThresholdL1:L1門限
static double ThresholdL1(double s, double l1) {const double reg_s = std::max(0.0, std::fabs(s) - l1);return Common::Sign(s) * reg_s;}其中s就是一階導數和,不難看出其返回的值是:、
(2)CalculateSplittedLeafOutput:計算分裂節點的輸出
static double CalculateSplittedLeafOutput(double sum_gradients, double sum_hessians, double l1, double l2, double max_delta_step) {double ret = -ThresholdL1(sum_gradients, l1) / (sum_hessians + l2);if (max_delta_step <= 0.0f || std::fabs(ret) <= max_delta_step) {return ret;} else {return Common::Sign(ret) * max_delta_step;}}不難看出其返回的值核心是:
注意其還有一個重載函數其實就是將結果進一步規范在了一個范圍內(min_constraint,max_contraint)
static double CalculateSplittedLeafOutput(double sum_gradients, double sum_hessians, double l1, double l2, double max_delta_step,double min_constraint, double max_constraint) {double ret = CalculateSplittedLeafOutput(sum_gradients, sum_hessians, l1, l2, max_delta_step);if (ret < min_constraint) {ret = min_constraint;} else if (ret > max_constraint) {ret = max_constraint;}return ret;}(3)GetLeafSplitGainGivenOutput:計算當前節點的熵
static double GetLeafSplitGainGivenOutput(double sum_gradients, double sum_hessians, double l1, double l2, double output) {const double sg_l1 = ThresholdL1(sum_gradients, l1);return -(2.0 * sg_l1 * output + (sum_hessians + l2) * output * output);}這里的output就是經過(2)函數的結果,從這里就可以清晰的看到在lightGBM真實實踐中計算當前節點熵的具體表達式,還記得在Xgboost推導的結算當前節點對應的熵的結果嗎?其大概形式是:(sum_gradients)*(sum_gradients)/(sum_hessians+L)即一階導數的平方除以二階導數加正則項的和,其和(2)的結果即這里的output比較像,只不過(2)的結果還給sum_gradients加了正則項L1(分母的正則項是+,分子的正則項是-,即帶來的效果保持了一致性),而且一階導數不是完全的平方,而是其中一個用到了激活函數(深度學習中通常這樣稱呼)sign,然后lgb最后的得到熵公式可以從上面看到。
對應的官方超參數有:
lambda_l1?:?default =?0.0, type = double, aliases:?reg_alpha, constraints:?lambda_l1?>=?0.0
- L1 regularization
lambda_l2?:?default =?0.0, type = double, aliases:?reg_lambda,?lambda, constraints:?lambda_l2?>=?0.0
- L2 regularization
(4)GetSplitGains:計算分裂后左右子樹的熵的和:
概括一下就是:其值=GetLeafSplitGainGivenOutput(left)+GetLeafSplitGainGivenOutput(right)
(5)GetLeafSplitGain:計算分裂前的熵
概括一下就是:其值等于:GetLeafSplitGainGivenOutput(left+right)
同時從這里可以看到:GetSplitGains-GetLeafSplitGain就是分裂后的增益
以上就是定義的基本操作函數,下面介紹的函數就是功能函數(直方圖尋找最佳切分點):
首先其可以看成是兩大類:
一:特征下的值是非連續的即所謂的類別特征。
二:特征下的值是連續的
下面先來看處理類別特征的相關函數,再來看處理連續特征的相關函數
(6)FindBestThresholdCategorical:處理處理類別特征。
其分為兩種情況:one-hot形式和非one-hot形式,one-hot形式其實是一種one VS many的情況,而非one-hot是一種many VS many形式
那么不言而喻第二種在大數據的情況下,好處多多,起碼考慮的情況更多,而且不至于樹深度太大,還有一個直觀的好處就是單一的分裂其實帶來的增益通常較少,因為每次僅僅從一大推信息中區分出那么一丁點信息,帶來的信息增益自然不會高,第二種則是不一樣。
加下來我們具體看看源碼:
當bin的數目小于meta_->config->max_cat_to_onehot時即類別數目較少時例如開關狀態只有兩種這時候就采用one-hot形式,否則不采用one-hot形式。
當采用one-hot形式時:遍歷每一個bin(類別),丟棄那些樣本少的類別以及總二階導數和少的樣本:
if (use_onehot) {for (int t = 0; t < used_bin; ++t) {// if data not enough, or sum hessian too smallif (data_[t].cnt < meta_->config->min_data_in_leaf|| data_[t].sum_hessians < meta_->config->min_sum_hessian_in_leaf) continue;data_size_t other_count = num_data - data_[t].cnt;// if data not enoughif (other_count < meta_->config->min_data_in_leaf) continue;double sum_other_hessian = sum_hessian - data_[t].sum_hessians - kEpsilon;// if sum hessian too smallif (sum_other_hessian < meta_->config->min_sum_hessian_in_leaf) continue;注意看:這里是綜合考慮左右子樹的即只要按當前的bin劃分出來的左右子樹有一個滿足拋棄條件即拋棄。同時這里也很好的體現了所謂的直方圖差加速概念,說白了就是我們只要得到比如左子樹,那么右子樹就直接可以使用總的減去左子樹得到,是不是很快!是不是很巧妙!這就是差加速的概念。
從這里可以看到ont-hot的one VS many形式:因為 不論是other_count還是sum_other_hessian等等都是除了當前這一類別之外其他所有類別的和。
最少樣本數和最少二階導數和樹均是配置參數:meta_->config->min_data_in_leaf,config->min_sum_hessian_in_leaf
然后在剩下的bin中使用GetSplitGains計算得到分裂后的左右子樹的熵的和current_gain,其先和min_gain_shift比較,
if (current_gain <= min_gain_shift) continue;min_gain_shift是最小熵其定義如下:
min_gain_shift = gain_shift + meta_->config->min_gain_to_split其中gain_shift是通過GetLeafSplitGain計算得到的未分裂前的熵,min_gain_to_split是一個配置參數,其含義就是當前分裂最小需要的增益。話句話說比如沒有分裂前熵是5,我們要求分裂后熵最少的增加2,所以當current_gain小于等于7時,說明利用該Bin分裂得到的增益不大,就不選用該bin作為分裂節點了,直接跳過。更直接點說就是分裂了還沒有沒分裂前熵大,那還分什么,對吧。
要是滿足了current_gain 大于?min_gain_shift,那么我們就判斷并更新:
if (current_gain > best_gain) {best_threshold = t;best_sum_left_gradient = data_[t].sum_gradients;best_sum_left_hessian = data_[t].sum_hessians + kEpsilon;best_left_count = data_[t].cnt;best_gain = current_gain;}這里的best_gain初始化值為kMinScore即最小得分,后續就是不斷隨著更新一直保持當前最大啦
當不采用one-hot形式時:
其首先遍歷bin,得到樣本多的bin,這一結果保存在sorted_idx中:
for (int i = 0; i < used_bin; ++i) {if (data_[i].cnt >= meta_->config->cat_smooth) {sorted_idx.push_back(i);}}當然了,這個多的衡量同樣是一個配置參數控制的即meta_->config->cat_smooth
然后對sorted_idx根據(一階導數/(二階導數+meta_->config->cat_smooth))的大小進行排序:
auto ctr_fun = [this](double sum_grad, double sum_hess) {return (sum_grad) / (sum_hess + meta_->config->cat_smooth);};接下來是兩個for循環,外面for循環代表的是方向即從左到右和從右到左兩種遍歷方式,為了便于理解這里舉一個簡單的例子,假設當前這一特征有4種類別:A,B,C,D,數學化后為0,1,2,3
那么我們先按照從左到右的順序遍歷,從0開始那么左樹類別就是0,右樹就是1,2,3,4計算增益比較更新,接著到1,那么左樹就是0和1,右樹就是2,3,4計算增益比較更新,接著到2,那么左樹就是0,1,2右樹就是3
其次我們按照從右到左的順序遍歷,從3開始,那么左樹就是3,右樹就是0,1,2計算增益比較更新,依次類推,,,,
代碼中find_direction是方向其是一個數組里面有(1,-1),start_position代表起始點其值是(0,used_bin - 1)
左到右即:從0開始每次加1;右到左即:從used_bin - 1開始每次加-1;
for (size_t out_i = 0; out_i < find_direction.size(); ++out_i) {auto dir = find_direction[out_i];auto start_pos = start_position[out_i];data_size_t min_data_per_group = meta_->config->min_data_per_group;data_size_t cnt_cur_group = 0;double sum_left_gradient = 0.0f;double sum_left_hessian = kEpsilon;data_size_t left_count = 0;for (int i = 0; i < used_bin && i < max_num_cat; ++i) {auto t = sorted_idx[start_pos];start_pos += dir;sum_left_gradient += data_[t].sum_gradients;sum_left_hessian += data_[t].sum_hessians;left_count += data_[t].cnt;cnt_cur_group += data_[t].cnt;從外層for可以看到,其是先從左到右的,?sum_left_gradient等累加的過程其實就是含義就是說將當前Bin的左面所有bin都歸為左子樹,毫無疑問當前Bin的右面所有bin就都歸為左子樹,就是此處出現了many VS many的身影!!!!!!!
當然了在遍歷的過程中,在源代碼中也可以看到同樣會考慮分裂后左右子樹的最少樣本數和最少二階導數和樹即用到meta_->config->min_data_in_leaf,config->min_sum_hessian_in_leaf。
同時在代碼中還有一點需要注意的就是:
if (cnt_cur_group < min_data_per_group) continue;這里的cnt_cur_group就是當前單個bin中的樣本數,min_data_per_group是一個配置選項,意思就是說當單個bin的data小于min_data_per_group就忽略掉,注意和min_data_in_leaf區分,min_data_in_leaf指的是當前被劃分到一邊(左或右)的所有bin的data數目。那么cat_smooth和min_data_per_group又是什么區別呢?看一下源碼的邏輯是這樣的:首先使用cat_smooth淘汰掉那些data小的bin,然后在剩下的bin中按照上述所說的排序,然后左右遍歷,遍歷的過程中又會根據min_data_per_group淘汰掉一部分小的data。
這里需要兩點說明:
<1>可以看到非one-hot 是一種many VS many的形式即有左子樹是0和1,右子樹是2,3這種情況,而在one-hot中是不會出現這種情況的,其只可能是左子樹是0,右子樹是1,2,3或左子樹是1,右子樹是0,2,3這種one VS many的形式。
<2>左右兩次遍歷的意義何在?其意義就在于缺省值到底是在哪里?其實這類問題叫做Sparsity-aware Split Finding稀疏感知算法,
當從左到右,對于缺省值就規劃到了右面,當方向相反時,缺省值都規劃到了左面,大家可以這樣想這個問題:
當從左到右時,我們記錄不論是當前一階導數和也好二階導數也罷,都是針對有值的(缺省值就沒有一階導數和二階導數),那么我們用差加速得到右子樹,既然左子樹沒有包括缺省值,那么總的減去左子樹自然就將缺省值歸到右子樹了,假如沒有缺省值,其實這里進行兩次方向的遍歷并沒有什么意義,為什么呢?假如最好的劃分是樣本1和樣本3在一邊,樣本2和樣本4在一邊,那么兩次方向遍歷無非就是對應下圖兩種情況:
有區別嗎?其實并沒有,因為下一次根據Leaf-wise原則無非就是選取左面和右面一個進行下去即可所以說1,3到底在左面還是右面并沒有關系,可是當有缺省值時就完全不一樣了,比如這里有一個缺省值5.于是上圖就變為:
看出不同了吧,其實兩次方向的遍歷說白了就是將缺省值分別放到左右看看到底哪邊好!!!!!!
<3>最后不論是one-hot還是非one-hot最后都會得到最佳分裂的Bin索引,記錄在了best_threshold中,當然了對應非one-hot還得記錄一個參數那就是方向?best_dir(1或者-1)
通過上面我們看到了一些參數(就是上文說的配置),下面我們結合官方給出的API,將其來還原到源碼中就會更加清楚其作用:
min_data_per_group?:default =?100, type = int, constraints:?min_data_per_group?>?0
- minimal number of data per categorical group
cat_smooth?:default =?10.0, type = double, constraints:?cat_smooth?>=?0.0
- used for the categorical features
- this can reduce the effect of noises in categorical features, especially for categories with few data
min_data_in_leaf?:default =?20, type = int, aliases:?min_data_per_leaf,?min_data,?min_child_samples, constraints:?min_data_in_leaf?>=?0
- minimal number of data in one leaf. Can be used to deal with over-fitting
min_sum_hessian_in_leaf?:default =?1e-3, type = double, aliases:?min_sum_hessian_per_leaf,?min_sum_hessian,?min_hessian,?min_child_weight, constraints:?min_sum_hessian_in_leaf?>=?0.0
- minimal sum hessian in one leaf. Like?min_data_in_leaf, it can be used to deal with over-fitting
max_cat_to_onehot?:default =?4, type = int, constraints:?max_cat_to_onehot?>?0
- when number of categories of one feature smaller than or equal to?max_cat_to_onehot, one-vs-other split algorithm will be used
注意這里所說的one-vs-other就是我們上述的one-vs-many,含義一樣。
min_gain_to_split?:default =?0.0, type = double, aliases:?min_split_gain, constraints:?min_gain_to_split?>=?0.0
- the minimal gain to perform split
看!是不是感覺一下明朗起來了,其實這些超參數大部分都是為了防止過擬合。
以上由于是第一次介紹相關的參數所以篇幅較長,盡量將所有的細節都介紹了,下面對相同的內容就不再重述啦!比如下面同樣使用了min_data_in_leaf?以及左右遍歷等伎倆,大家明白其目的就好了。
(7)FindBestThresholdNumerical:處理連續特征
該函數只是一個表象,其真真分裂算法核心在于(8),那么這里主要是做了一個判斷,即是否將0看為缺省值,為此進行了不同的處理:
if (meta_->missing_type == MissingType::Zero) {FindBestThresholdSequence(sum_gradient, sum_hessian, num_data, min_constraint, max_constraint, min_gain_shift, output, -1, true, false);FindBestThresholdSequence(sum_gradient, sum_hessian, num_data, min_constraint, max_constraint, min_gain_shift, output, 1, true, false);} else {FindBestThresholdSequence(sum_gradient, sum_hessian, num_data, min_constraint, max_constraint, min_gain_shift, output, -1, false, true);FindBestThresholdSequence(sum_gradient, sum_hessian, num_data, min_constraint, max_constraint, min_gain_shift, output, 1, false, true);}通過對比大家可以看到兩者的不同之處在于調用(8)函數時,最后兩個參數不同,這兩個參數是Bool類型含義如下:
bool skip_default_bin, bool use_na_as_missing第一個代表是否跳過默認bin,第二個含義是是否使用NaN作為缺省值。同時可以看道不論那種情況,都是調用了兩遍(8)函數,兩次的不同在于1或-1,該字段的意思是方向,1代表從左到右,-1代表是從右到左。所以我們還是將重點放在(8)函數吧!
(8)FindBestThresholdSequence:處理連續特征的分裂算法核心
這里核心的東西:
double current_gain = GetSplitGains(sum_left_gradient, sum_left_hessian, sum_right_gradient, sum_right_hessian,meta_->config->lambda_l1, meta_->config->lambda_l2, meta_->config->max_delta_step,min_constraint, max_constraint, meta_->monotone_type);// gain with split is worse than without splitif (current_gain <= min_gain_shift) continue;// mark to is splittableis_splittable_ = true;// better split pointif (current_gain > best_gain) {best_left_count = left_count;best_sum_left_gradient = sum_left_gradient;best_sum_left_hessian = sum_left_hessian;// left is <= threshold, right is > threshold. so this is t-1best_threshold = static_cast<uint32_t>(t - 1 + bias);best_gain = current_gain;}可以看到和處理類別特征非one-hot形式一樣,方向的話這里就簡單判斷了一下:是-1時從右遍歷,1是從左:
-1時:
const int t_end = 1 - bias;// from right to left, and we don't need data in bin0for (; t >= t_end; --t) {............1時:
t = -1;for (; t <= t_end; ++t) {.............有兩點需要注意:
<1>當遇見默認的Bin時需要跳過:
if (skip_default_bin && (t + bias) == static_cast<int>(meta_->default_bin)) { continue; }當將0也視為缺省值時是需要跳過默認的bin 的,而將只將NaN視為缺省值時是不需要跳過默認的bin 的
<2>在沒將0也視為缺省值時需要進行的特殊處理是:
注意這一過程只在從左到右這一方向做:
if (use_na_as_missing && bias == 1) {sum_left_gradient = sum_gradient;sum_left_hessian = sum_hessian - kEpsilon;left_count = num_data;for (int i = 0; i < meta_->num_bin - bias; ++i) {sum_left_gradient -= data_[i].sum_gradients;sum_left_hessian -= data_[i].sum_hessians;left_count -= data_[i].cnt;}t = -1;}結合上面缺省值的分析,假設特征值下是0,那么是不是也相當于沒計數,所以代碼中并沒有進行什么處理就是左右遍歷兩次,相當于將0放到左右看看哪個好?但是當不將其視為缺省值,即這里的use_na_as_missing為真時,我們就要將bin最右邊偏離bias為止的所有bin默認為了左子樹。注意在從右到左的這一過程和use_na_as_missing并沒有什么關系,也就是說將0劃分到了左面,但在從左到右的時候按以前的話應該將其劃分到右面了,但這里采用了默認還是左子樹的做法(看似道理正確,但是還是有一點小糾結,還望大佬指正,筆者也再想想,好了接著往下寫吧)
對應官方超參數的API:
use_missing? :default =?true, type = bool
- set this to?false?to disable the special handle of missing value
zero_as_missing?:?default =?false, type = bool
- set this to?true?to treat all zero as missing values (including the unshown values in libsvm/sparse matrices)
- set this to?false?to use?na?for representing missing values
接著說一下HistogramPool這個類:
說白了該類主要就是構建data_的信息,其中包括bin等等,深入到代碼中你會發現其主要是使用了Dataset這個數據集,例如下面的train_data:
void DynamicChangeSize(const Dataset* train_data, const Config* config, int cache_size, int total_size) {if (feature_metas_.empty()) {int num_feature = train_data->num_features();feature_metas_.resize(num_feature);而這個Dataset類是在通過頭文件中導入的:
#include <LightGBM/dataset.h>于是可以找到對應的源代碼,仔細看其細節:
該類具體在LightGBM/include/LightGBM/dataset.h,大約在282行就可以看到其定義,這是頭文件,其只是定義了一些接口,其主要實現細節即.cpp是在LightGBM/src/io/dataset.cpp位置。
該類有很多屬性和方法,其中比較重要的方法就是:ConstructHistograms方法即構造直方圖方法
從該方法中可以看到很多細節,例如使不使用二階導數,不使用時會將其視為一個常數:
if (!is_constant_hessian) {#pragma omp parallel for schedule(static)for (data_size_t i = 0; i < num_data; ++i) {ordered_gradients[i] = gradients[data_indices[i]];ordered_hessians[i] = hessians[data_indices[i]];}} else {#pragma omp parallel for schedule(static)for (data_size_t i = 0; i < num_data; ++i) {ordered_gradients[i] = gradients[data_indices[i]];}}這里的data_indices[i]就是具體到每一個樣本,可以看到,當不使用二階導數時即else部分就沒有記錄具體每個樣本的二階導數。但一階導數都是始終記錄的。
說到這里我們有必要另外起一個分界線了:因為下面涉及到LightGBM論文中提到的兩大技術之一:EFB
----------------------------------------------------------------------------------------------------------------------------------------------------------------
EFB
上述我們已經簡單介紹過了,在原論文中給出特征捆綁算法:
首先說一下其原理:(本人理解有誤或大家有疑惑的可以去看原論文4.1部分)
大部分高緯度的數據集都是稀疏的,這就為我們捆綁特征帶來了可能性,特征的稀疏就說明很多特征是相互排斥的,例如它們不總是同時取非0值,所以我們可以很放心的將多個特征捆綁為一個特征,所以復雜度就從(#data*#feature)降為(#data*#bundle),其中bundle就是經過捆綁后的特征數,通常bundle遠小于feature。
需要點(論文2.2第三段):這和以往減少特征有著很大的區別,以往采用的都是例如PCA這種,但是這種算法有一個大前提那就是
特征有冗余性比如:動物類別,是否是狗(是筆者自己舉的例子),很明顯兩個特征其實有冗余性,但是這種情況并不是出現,當特征沒有這種冗余性的時候,這種算法就遜色很多了,于是LightGBM在特征降維這個問題上,提出了EFB來解決這一棘手問題。
到此就面臨到兩個待解決的問題:(1)到底那些特征需要合并到一起(2)怎么合并到一起
其中(1)是采用圖涂色算法,同時注意到有些特征并不是100%的互相排斥,但是呢?其也很少同時取非0值,如果我們允許一部分沖突,那么這部分特征就可以進一步進行合并,使得bundle進一步減少。
說了這么多可能大家還是一頭霧水,相互排斥到底是個什么東西?下面就一種理想情況畫一張圖直觀的看一下其原理:
假設現在有13個樣本,每個樣本有四個特征A,B,C,D,可以看到這很稀疏了吧(左圖),那么怎么合并呢?很簡單將ABCD捆綁為一個特征M就是右圖
是不是感覺很眼熟,是的其逆過程即從右圖到左圖就是有種ont-hot的味道。
知道了何謂排斥那么第一個問題就解決了,再來看第二個問題,具體怎么合并,上面是一種比較極端的情況,一般的情況是這樣:
假如A特征的范圍是[0,10),B特征的范圍是[0,20),那么就給B特征加一個偏值,比如10,那么B的范圍就變為[10,30),所以捆綁為一個特征后范圍就是[0,30]。算法對應右圖
所以結合兩個問題來看其完成的任務不但是簡簡單單的捆綁,而且要達到捆綁后還能從取值上區分出特征之間的關系。
上了上面的理論再看左邊的算法就很簡單了,大概就是先計算當前特征和當前bundle沖突,沖突小就將當前特征捆綁到當前bundle中,否則就再重新建一個bundle。需要注意的是該過程只需要在最開始做一次就好了,后面就都用捆綁好的bundle,其算法復雜度顯而易見是#feature*#feature,但是當特征緯度過高,這顯然也是不好的,于是乎對算法進行了改進,其不再建立圖了,而是統計非零的個數,非零個數越多就說明沖突越大,互相排斥越小,越不能捆綁到一起。
有了上面理論,我們就看看你源碼中的部分吧:
(1)?GetConfilctCount:這里就是計算沖突樹的地方
大體可以其就是在統計非零個數。
int GetConfilctCount(const std::vector<bool>& mark, const int* indices, int num_indices, int max_cnt) {int ret = 0;for (int i = 0; i < num_indices; ++i) {if (mark[indices[i]]) {++ret;if (ret > max_cnt) {return -1;}}}return ret; }(2)FindGroups:解決上述問題一即哪些特征需要合并
這里就看一下最關鍵的部分大約在105行
for (auto gid : search_groups) {const int rest_max_cnt = max_error_cnt - group_conflict_cnt[gid];int cnt = GetConfilctCount(conflict_marks[gid], sample_indices[fidx], num_per_col[fidx], rest_max_cnt);if (cnt >= 0 && cnt <= rest_max_cnt) {data_size_t rest_non_zero_data = static_cast<data_size_t>(static_cast<double>(cur_non_zero_cnt - cnt) * num_data / total_sample_cnt);if (rest_non_zero_data < filter_cnt) { continue; }need_new_group = false;features_in_group[gid].push_back(fidx);group_conflict_cnt[gid] += cnt;group_non_zero_cnt[gid] += cur_non_zero_cnt - cnt;MarkUsed(conflict_marks[gid], sample_indices[fidx], num_per_col[fidx]);if (is_use_gpu) {group_num_bin[gid] += bin_mappers[fidx]->num_bin() + (bin_mappers[fidx]->GetDefaultBin() == 0 ? -1 : 0);}break;}}if (need_new_group) {features_in_group.emplace_back();features_in_group.back().push_back(fidx);group_conflict_cnt.push_back(0);conflict_marks.emplace_back(total_sample_cnt, false);MarkUsed(conflict_marks.back(), sample_indices[fidx], num_per_col[fidx]);group_non_zero_cnt.emplace_back(cur_non_zero_cnt);if (is_use_gpu) {group_num_bin.push_back(1 + bin_mappers[fidx]->num_bin() + (bin_mappers[fidx]->GetDefaultBin() == 0 ? -1 : 0));}}其首先通過?GetConfilctCount計算沖突數,如果符合要求就將其捆綁當前的bundle即源碼中features_in_group,并且將need_new_group設置為false意思是不用新建bundle啦,否則就新建,并且將當前feature的id捆綁當其中即代碼中的:
features_in_group.emplace_back(); features_in_group.back().push_back(fidx);最后改函數返回的就是features_in_group即分好的bundle。
return features_in_group;(3)FastFeatureBundling:進一步捆綁
捆綁過程還沒有結束,該函數對捆綁做了進一步處理:
該函數遍歷經過(2)的初步捆綁的bundle,保留了哪些只有一個特征和5個以上的bundle,對于其他的bundle做了如下處理:
對哪些稀疏度低的bundle,將其進行了拆分,又一次還原到了初始的正式特征,換句話說就是解散了稀疏度低,排斥性小,沖突大的bundle。
這里的稀疏度計算方法很簡單就是:1-非零值數目/總數目
double sparse_rate = 1.0f - static_cast<double>(cnt_non_zero) / (num_data);然后和一個門限值?sparse_threshold比較,低就拆分:
if (sparse_rate >= sparse_threshold && is_enable_sparse) {for (size_t j = 0; j < features_in_group[i].size(); ++j) {const int fidx = features_in_group[i][j];ret.emplace_back();ret.back().push_back(fidx);}} else {ret.push_back(features_in_group[i]);}該函數結尾還將這些分好的bundle進行了打亂:
int num_group = static_cast<int>(ret.size());Random tmp_rand(12);for (int i = 0; i < num_group - 1; ++i) {int j = tmp_rand.NextShort(i + 1, num_group);std::swap(ret[i], ret[j]);}最后結果就保存在了ret中,該函數最后就返回了ret
------------------------------------------------------------------------------------------------------------------------------------------------------------------
GOSS:
GOSS是論文提出兩大技術的另一個,其算法如下:
對于稀疏數據集來說:
首先GBDT如果采用pre-sorted方式進行分裂可以通過忽略掉大部分值為0特征來減少復雜度(具體怎么做有興趣的可以看一下原論文參考文獻13),但是我們說了使用histogram-based的好處多多,但是GBDT如果使用了histogram-based形式,則沒有了相關的稀疏優化方法,因為histogram-based需要遍歷所有的數據的bin值,而不會管其值是不是0,同時呢?我們知道傳統的Adaboost其實數據集都是有一個權值的,用來衡量其重要程度,沒有被好好訓練的樣本其權值就大,以便下一個基學習器對其多加訓練,于是就可以依據該權值對其采用,這樣就做到采用利用部分數據集,但是呢?我們知道在GBDT中是沒有權值這一說的,其每次利用的都是整個數據集,其這些數據集的權重是一樣的,所以怎么辦呢?
于是乎lightGBM提出了GOSS,其是這樣想的:
抽樣肯定還是要抽的,畢竟減少了樣本減少了復雜度嘛!沒有權值我們根據什么抽呢?其發現可以將一階導數看做權重,一階導數大的說明離最優解還遠,這部分樣本帶來的增益大,或者說這部分樣本還沒有被好好訓練,下一步我們應該重點訓練他們。
對應的是右圖的算法,a代表對大梯度樣本的采樣率,b代表對小梯度樣本的采樣率,首先對梯度排序得到sorted,前后取前topN作為大梯度樣本集合topSet(topN的個數是通過a確定的),然后在剩下的里面隨機抽取(RandomPick為隨機抽取算法)randN個作為小梯度樣本集合randSet,最后將兩者合并作為采用后的樣本usedSet,我們就拿這個樣本取訓練,同時呢為了盡可能不改變數據集的概率分布(因為這樣抽的結果就是小梯度的樣本被不斷的減少再減少),所以還有給小樣本一個補償,那就是乘以一個常數即(1-a)/b,可以看到當a=0時就變成了隨機采用啦,這樣抽的結果還是能保持準確率的,這里有詳細的數學證明,請看論文的3.2部分。
下面來看一下源碼:
其.h文件位于LightGBM/src/boosting/goss.hpp
該類下主要有如下方法:
Init
ResetTrainingData
ResetGoss
Bagging
BaggingHelper
(1)Init、ResetTrainingData、ResetGoss
前三個方法都可以簡單將其看為GOSS的一些初始化。都很簡單,看一下源碼就大概明白了,這里順便簡單說一下ResetGoss中的幾個注意點:
首先看如下代碼:
CHECK(config_->top_rate + config_->other_rate <= 1.0f); CHECK(config_->top_rate > 0.0f && config_->other_rate > 0.0f);if (config_->bagging_freq > 0 && config_->bagging_fraction != 1.0f) {Log::Fatal("Cannot use bagging in GOSS");}Log::Info("Using GOSS");這里的的top_rate和other_rate就是我們上面理論部分說的a,b,正如代碼看到的兩則都必須大于0且和小于1,否則就不能用GOSS,同時還會發現,當bagging_freq大于0且bagging_fraction不等于1時也是不能用GOSS的,對應到官方API如下
top_rate?:default =?0.2, type = double, constraints:?0.0?<=?top_rate?<=?1.0
- used only in?goss
- the retain ratio of large gradient data
other_rate?:default =?0.1, type = double, constraints:?0.0?<=?other_rate?<=?1.0
- used only in?goss
- the retain ratio of small gradient data
bagging_fraction?:default =?1.0, type = double, aliases:?sub_row,?subsample,?bagging, constraints:?0.0?<?bagging_fraction?<=?1.0
- like?feature_fraction, but this will randomly select part of data without resampling
- can be used to speed up training
- can be used to deal with over-fitting
- Note: to enable bagging,?bagging_freq?should be set to a non zero value as well
bagging_freq?:default =?0, type = int, aliases:?subsample_freq
- frequency for bagging
- 0?means disable bagging;?k?means perform bagging at every?k?iteration
- Note: to enable bagging,?bagging_fraction?should be set to value smaller than?1.0?as well
其次:
if (config_->top_rate + config_->other_rate <= 0.5) {auto bag_data_cnt = static_cast<data_size_t>((config_->top_rate + config_->other_rate) * num_data_);bag_data_cnt = std::max(1, bag_data_cnt);tmp_subset_.reset(new Dataset(bag_data_cnt));tmp_subset_->CopyFeatureMapperFrom(train_data_);is_use_subset_ = true;}// flag to not bagging firstbag_data_cnt_ = num_data_;可以看到當a+b小于等于0.5,就可以用GOSS,其中bag_data_cnt就是抽樣后的樣本數,num_data_是總樣本數,當大于0.5時,就暫時先不進行GOSS了。
(2)Bagging
這部分就是表象,主要就是處理了一些線程的東西,而真正的GOSS算法是在(3)BaggingHelper,所以下面會重點說一下(3)的函數。本函數也注意幾點:
Random cur_rand(config_->bagging_seed + iter * num_threads_ + i);data_size_t cur_left_count = BaggingHelper(cur_rand, cur_start, cur_cnt,tmp_indices_.data() + cur_start, tmp_indice_right_.data() + cur_start);這里的cur_rand就是上面所說的隨機抽取小梯度時用的seed數,可以看到其是下面(3)函數的一個參數,其對應的官方API:
bagging_seed?:default =?3, type = int, aliases:?bagging_fraction_seed
- random seed for bagging
最后看到將抽樣后的數據設置為了訓練樹的訓練數據集,那么我們具體看一下tmp_subset_是怎么來的:
tmp_subset_->ReSize(bag_data_cnt_); tmp_subset_->CopySubset(train_data_, bag_data_indices_.data(), bag_data_cnt_, false);這里是先設置了bag_data_cnt(上面我們已經說過了)大小的空間,然后將bag_data_indices_? 復制了過來,那么再看一下bag_data_indices_
std::memcpy(bag_data_indices_.data() + left_write_pos_buf_[i],tmp_indices_.data() + offsets_buf_[i], left_cnts_buf_[i] * sizeof(data_size_t));它又是通過tmp_indices_復制來的,再看一下tmp_indices_:
data_size_t cur_left_count = BaggingHelper(cur_rand, cur_start, cur_cnt,tmp_indices_.data() + cur_start, tmp_indice_right_.data() + cur_start);會發現最終還是定位到了(3)函數,所以我們就來看看(3)函數吧!!!!!!!!!!
(3)BaggingHelper
data_size_t top_k = static_cast<data_size_t>(cnt * config_->top_rate);data_size_t other_k = static_cast<data_size_t>(cnt * config_->other_rate);top_k和?other_k 就是我們要抽取的大梯度數據集和小梯度數據集的樣本數
ArrayArgs<score_t>::ArgMaxAtK(&tmp_gradients, 0, static_cast<int>(tmp_gradients.size()), top_k - 1); score_t threshold = tmp_gradients[top_k - 1];這里的threshold就是門限,梯度大于該門限的我們都抽取,因為這里先對tmp_gradients進行了排序,然后選取了索引為top_k - 1作為門限值,可想而知,大于該門限值的一共就是top_k,那么是根據什么對tmp_gradients排序的呢?
mp_gradients[i] += std::fabs(gradients_[idx] * hessians_[idx]);會發現其和理論部分還有有點不一樣,理論部分只是高度概括使用導數(一階導數),實際上這里是是使用了一階導數和二階導數的成績進行排序的。
score_t multiply = static_cast<score_t>(cnt - top_k) / other_k;multiply就是理論部分所說的對小梯度集合的補償常數(1-a)/b,注意這里直接使用了樣本數,結果是一樣的:
樣本總數*(1-a)/b=(樣本總數-大梯度樣本數)/小梯度樣本數=(cnt - top_k) / other_k
下面部分算是最核心的部分了吧。
首先結果是保存在了buffer中的,也就是對應(2)函數的tmp_indices_
if (grad >= threshold) {buffer[cur_left_cnt++] = start + i;++big_weight_cnt;}可以看到對大于threshold的樣本那就是大梯度樣本直接保存到buffer中,如果是小梯度,除了保存之外,還需要補償工作:
double prob = (rest_need) / static_cast<double>(rest_all);if (cur_rand.NextFloat() < prob) {可以看到這里的prob=還需要抽取小梯度數/總共需要抽取小梯度數,如果小于其值進行補償:
gradients_[idx] *= multiply;hessians_[idx] *= multiply;當然了別忘了還要將該樣本保存到buffer中作為小梯度中抽取的樣本:
buffer[cur_left_cnt++] = start + i;如果大于該值,就直接進行:
buffer[cur_left_cnt++] = start + i;無需補償。
-------------------------------------------------------------------------------------------------------------------------------------------------------------------
Leaf-wise
Level-wise是將同一層所有的葉子節點進行分裂,這也是以前GBDT各種演變算法所采用的策略,而LightGBM采用的Leaf-wise是每次選取最大增益的那個葉子節點進行分類,可以看到生長相同的leaf時,leaf-wise 算法可以比 level-wise 算法減少更多的損失即得到更大的增益,但是其缺點就是可能會長出比較深的決策樹,產生過擬合。因此LightGBM在Leaf-wise之上增加了一個最大深度限制,在保證高效率的同時防止過擬合。
下面來看一下源碼:
(1)首先看SerialTreeLearner::BeforeFindBestSplit這個方法:
該方法就是在尋找最佳劃分點之前需要做哪些工作,最終返回的是一個bool類型,false代表不要再往下分裂了,true代表接著分。
其中一點就包括我們上面所說的檢查當前樹的深度,如果已經超過了配置選項就不在分裂了:
if (tree->leaf_depth(left_leaf) >= config_->max_depth) {best_split_per_leaf_[left_leaf].gain = kMinScore;if (right_leaf >= 0) {best_split_per_leaf_[right_leaf].gain = kMinScore;}return false;需注意這里僅僅檢查了左樹的深度就夠了,因為leaf-wise這種方法從上面圖中也可以看出,左右深度是一樣的,檢查一邊就好了。這里的max_depath對應的官方API:
max_depth?:default =?-1, type = int
- limit the max depth for tree model. This is used to deal with over-fitting when?#data?is small. Tree still grows leaf-wise
- <?0?means no limit
當然了,該方法中還有其他檢查選項類如,當數據不夠多時也是不會往下分裂的:
if (num_data_in_right_child < static_cast<data_size_t>(config_->min_data_in_leaf * 2)&& num_data_in_left_child < static_cast<data_size_t>(config_->min_data_in_leaf * 2)) {best_split_per_leaf_[left_leaf].gain = kMinScore;if (right_leaf >= 0) {best_split_per_leaf_[right_leaf].gain = kMinScore;}return false;}這里的num_data_in_right_child和num_data_in_left_child分別代表左右子樹的數據量,可以看到多兩邊的數據量同時小于門限值的2倍時就不分裂了,其中門限值min_data_in_leaf是配置選項,對應官網API:
min_data_in_leaf?:default =?20, type = int, aliases:?min_data_per_leaf,?min_data,?min_child_samples, constraints:?min_data_in_leaf?>=?0
- minimal number of data in one leaf. Can be used to deal with over-fitting
其他這里不講了,因為我們重點要看Leaf-wise相關內容,總之該方法的作用可以簡單看做是為了防止過擬合采取的一系列手段。
(2)SerialTreeLearner::Train
for (int split = init_splits; split < config_->num_leaves - 1; ++split) {可以看到其先遍歷當前層的所有的葉子結點(上述理論部分是2個,實際上不僅僅是2個),其中num_leaves是葉子總數,配置選項,官網API:
num_leaves?:default =?31, type = int, aliases:?num_leaf,?max_leaves,?max_leaf, constraints:?num_leaves?>?1
- max number of leaves in one tree
得到best_leaf,然后進行劃分Split。
其他部分都是樹的訓練了,和以前GBDT本質上沒有什么不同,例如這里的Spilt具體的劃分函數等等,這小節我們只看和Leaf-wise技術有關的代碼,其他部分感興趣可以深入研究。
------------------------------------------------------------------------------------------------------------------------------------------------------------------
其他部分:
比較重點的還有GBDT部分,即集成學習boosting部分,這里和以前的提升算法沒有什么大的不同,即LightGBM重點提到的技術就是我們上面所說的,如果對其感興趣可以進一步研究,這部分代碼位置位于LightGBM/src/boosting/
---------------------------------------------------------------------------------------------------------------------------------------------------------------------
結束:
每天進步一點點!!!!!
看到很多小伙伴私信和關注,為了不迷路,歡迎大家關注筆者的微信公眾號,會定期發一些關于NLP的干活總結和實踐心得,當然別的方向也會發,一起學習:
???????
?
總結
以上是生活随笔為你收集整理的LightGBM源码阅读+理论分析(处理特征类别,缺省值的实现细节)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【Google Play】创建和管理内部
- 下一篇: 毒鸡汤