1. XGBoost簡介
XGBoost的全稱是eXtreme Gradient Boosting,它是經過優化的分布式梯度提升庫,旨在高效、靈活且可移植。XGBoost是大規模並行boosting tree的工具,它是目前最快最好的開源 boosting tree工具包,比常見的工具包快10倍以上。在數據科學方面,有大量的Kaggle選手選用XGBoost進行數據挖掘比賽,是各大數據科學比賽的必殺武器;在工業界大規模數據方面,XGBoost的分布式版本有廣泛的可移植性,支持在Kubernetes、Hadoop、SGE、MPI、 Dask等各個分布式環境上運行,使得它可以很好地解決工業界大規模數據的問題。本文將從XGBoost的數學原理和工程實現上進行介紹,然后介紹XGBoost的優缺點,並在最后給出面試中經常遇到的關於XGBoost的問題。
2. XGBoost的原理推導
2.1 從目標函數開始,生成一棵樹
XGBoost和GBDT兩者都是boosting方法,除了工程實現、解決問題上的一些差異外,最大的不同就是目標函數的定義。因此,本文我們從目標函數開始探究XGBoost的基本原理。
2.1.1 學習第 棵樹
XGBoost是由 個基模型組成的一個加法模型,假設我們第
次迭代要訓練的樹模型是
,則有:
則有:
2.1.2 XGBoost的目標函數
損失函數可由預測值 與真實值
進行表示:
其中, 為樣本的數量。
我們知道模型的預測精度由模型的偏差和方差共同決定,損失函數代表了模型的偏差,想要方差小則需要在目標函數中添加正則項,用於防止過擬合。所以目標函數由模型的損失函數 與抑制模型復雜度的正則項
組成,目標函數的定義如下:
其中, 是將全部
棵樹的復雜度進行求和,添加到目標函數中作為正則化項,用於防止模型過度擬合。
由於XGBoost是boosting族中的算法,所以遵從前向分步加法,以第 步的模型為例,模型對第
個樣本
的預測值為:
其中, 是由第
步的模型給出的預測值,是已知常數,
是這次需要加入的新模型的預測值。此時,目標函數就可以寫成:
注意上式中,只有一個變量,那就是第 棵樹
,其余都是已知量或可通過已知量可以計算出來的。細心的同學可能會問,上式中的第二行到第三行是如何得到的呢?這里我們將正則化項進行拆分,由於前
棵樹的結構已經確定,因此前
棵樹的復雜度之和可以用一個常量表示,如下所示:
2.1.3 泰勒公式展開
泰勒公式是將一個在 處具有
階導數的函數
利用關於
的
次多項式來逼近函數的方法。若函數
在包含
的某個閉區間
上具有
階導數,且在開區間
上具有
階導數,則對閉區間
上任意一點
有:
其中的多項式稱為函數在 處的泰勒展開式,
是泰勒公式的余項且是
的高階無窮小。
根據泰勒公式,把函數 在點
處進行泰勒的二階展開,可得如下等式:
回到XGBoost的目標函數上來, 對應損失函數
,
對應前
棵樹的預測值
,
對應於我們正在訓練的第
棵樹
,則可以將損失函數寫為:
其中, 為損失函數的一階導,
為損失函數的二階導,注意這里的求導是對
求導。
我們以平方損失函數為例:
則:
將上述的二階展開式,帶入到XGBoost的目標函數中,可以得到目標函數的近似值:
由於在第 步時
其實是一個已知的值,所以
是一個常數,其對函數的優化不會產生影響。因此,去掉全部的常數項,得到目標函數為:
所以我們只需要求出每一步損失函數的一階導和二階導的值(由於前一步的 是已知的,所以這兩個值就是常數),然后最優化目標函數,就可以得到每一步的
,最后根據加法模型得到一個整體模型。
2.1.4 定義一棵樹
我們知道XGBoost的基模型不僅支持決策樹,還支持線性模型,本文我們主要介紹基於決策樹的目標函數。我們可以重新定義一棵決策樹,其包括兩個部分:
- 葉子結點的權重向量
;
- 實例(樣本)到葉子結點的映射關系
(本質是樹的分支結構);
2.1.5 定義樹的復雜度
決策樹的復雜度 可由葉子數
組成,葉子節點越少模型越簡單,此外葉子節點也不應該含有過高的權重
(類比 LR 的每個變量的權重),所以目標函數的正則項由生成的所有決策樹的葉子節點數量,和所有節點權重所組成的向量的
范式共同決定。
2.1.6 葉子結點歸組
我們將屬於第 個葉子結點的所有樣本
划入到一個葉子結點的樣本集合中,數學表示為:
,那么XGBoost的目標函數可以寫成:
上式中的第二行到第三行可能看的不是特別明白,這里做些解釋:第二行是遍歷所有的樣本后求每個樣本的損失函數,但樣本最終會落在葉子節點上,所以我們也可以遍歷葉子節點,然后獲取葉子節點上的樣本集合,最后再求損失函數。即我們之前是單個樣本,現在都改寫成葉子結點的集合,由於一個葉子結點有多個樣本存在,因此才有了 和
這兩項,
為第
個葉子節點取值。
為簡化表達式,我們定義 ,
,含義如下:
:葉子結點
所包含樣本的
一階偏導數
累加之和,是一個常量;:葉子結點
所包含樣本的
二階偏導數
累加之和,是一個常量;
將 和
帶入XGBoost的目標函數,則最終的目標函數為:
這里我們要注意 和
是前
步得到的結果,其值已知可視為常數,只有最后一棵樹的葉子節點
不確定。
2.1.7 樹結構打分
回憶一下初中數學知識,假設有一個一元二次函數,形式如下:
我們可以套用一元二次函數的最值公式輕易地求出最值點:
那么回到XGBoost的最終目標函數上 ,該如何求出它的最值呢?
我們先簡單分析一下上面的式子:
- 對於每個葉子結點
,可以將其從目標函數中拆解出來:
在2.1.5中我們提到, 和
相對於第
棵樹來說是可以計算出來的。那么,這個式子就是一個只包含一個變量葉子結點權重
的一元二次函數,我們可以通過最值公式求出它的最值點。
- 再次分析一下目標函數
,可以發現,各個葉子結點的目標子式是相互獨立的,也就是說,當每個葉子結點的子式都達到最值點時,整個目標函數
才達到最值點。
那么,假設目前樹的結構已經固定,套用一元二次函數的最值公式,將目標函數對 求一階導,並令其等於
,則可以求得葉子結點
對應的權值:
所以目標函數可以化簡為:
上圖給出目標函數計算的例子,求每個節點每個樣本的一階導數 和二階導數
,然后針對每個節點對所含樣本求和得到
和
,最后遍歷決策樹的節點即可得到目標函數。
2.2 一棵樹的生成細節
2.2.1 最優切分點划分算法
在實際訓練過程中,當建立第 棵樹時,一個非常關鍵的問題是如何找到葉子節點的最優切分點,XGBoost支持兩種分裂節點的方法——貪心算法和近似算法。
(1)貪心算法
從樹的深度為0開始:
- 對每個葉節點枚舉所有的可用特征;
- 針對每個特征,把屬於該節點的訓練樣本根據該特征值進行升序排列,通過線性掃描的方式來決定該特征的最佳分裂點,並記錄該特征的分裂收益;
- 選擇收益最大的特征作為分裂特征,用該特征的最佳分裂點作為分裂位置,在該節點上分裂出左右兩個新的葉節點,並為每個新節點關聯對應的樣本集;
- 回到第1步,遞歸執行直到滿足特定條件為止;
那么如何計算每個特征的分裂收益呢?
假設我們在某一節點完成特征分裂,則分裂前的目標函數可以寫為:
分裂后的目標函數為:
則對於目標函數來說,分裂后的收益為:
注意:該特征收益也可作為特征重要性輸出的重要依據。
對於每次分裂,我們都需要枚舉所有特征可能的分割方案,如何高效地枚舉所有的分割呢?
假設我們要枚舉某個特征所有 這樣條件的樣本,對於某個特定的分割點
我們要計算
左邊和右邊的導數和。
我們可以發現對於所有的分裂點 ,只要做一遍從左到右的掃描就可以枚舉出所有分割的梯度和
、
。然后用上面的公式計算每個分割方案的收益就可以了。
觀察分裂后的收益,我們會發現節點划分不一定會使得結果變好,因為我們有一個引入新葉子的懲罰項,也就是說引入的分割帶來的增益如果小於一個閥值的時候,我們可以剪掉這個分割。
(2)近似算法
貪心算法可以得到最優解,但當數據量太大時則無法讀入內存進行計算,近似算法主要針對貪心算法這一缺點給出了近似最優解。
對於每個特征,只考察分位點可以減少計算復雜度。
該算法首先根據特征分布的分位數提出候選划分點,然后將連續型特征映射到由這些候選點划分的桶中,然后聚合統計信息找到所有區間的最佳分裂點。
在提出候選切分點時有兩種策略:
- Global:學習每棵樹前就提出候選切分點,並在每次分裂時都采用這種分割;
- Local:每次分裂前將重新提出候選切分點。
直觀上來看,Local策略需要更多的計算步驟,而Global策略因為節點已有划分所以需要更多的候選點。
下圖給出不同種分裂策略的AUC變化曲線,橫坐標為迭代次數,縱坐標為測試集AUC,eps為近似算法的精度,其倒數為桶的數量。
從上圖我們可以看到, Global 策略在候選點數多時(eps 小)可以和 Local 策略在候選點少時(eps 大)具有相似的精度。此外我們還發現,在eps取值合理的情況下,分位數策略可以獲得與貪心算法相同的精度。
近似算法簡單來說,就是根據特征 的分布來確定
個候選切分點
,然后根據這些候選切分點把相應的樣本放入對應的桶中,對每個桶的
進行累加。最后在候選切分點集合上貪心查找。該算法描述如下:
算法講解:
- 第一個for循環:對特征k根據該特征分布的分位數找到切割點的候選集合
。這樣做的目的是提取出部分的切分點不用遍歷所有的切分點。其中獲取某個特征k的候選切割點的方式叫
proposal
(策略)。XGBoost 支持 Global 策略和 Local 策略。 - 第二個for循環:將每個特征的取值映射到由該特征對應的候選點集划分的分桶區間,即
。對每個桶區間內的樣本統計值 G,H並進行累加,最后在這些累計的統計量上尋找最佳分裂點。這樣做的目的是獲取每個特征的候選分割點的 G,H值。
下圖給出近似算法的具體例子,以三分位為例:
根據樣本特征進行排序,然后基於分位數進行划分,並統計三個桶內的 G,H 值,最終求解節點划分的增益。
2.2.2 加權分位數縮略圖
實際上,XGBoost不是簡單地按照樣本個數進行分位,而是以二階導數值 作為樣本的權重進行划分。為了處理帶權重的候選切分點的選取,作者提出了Weighted Quantile Sketch算法。加權分位數略圖算法提出了一種數據結構,這種數據結構支持merge和prune操作。作者在論文中給出了該算法的詳細描述和證明鏈接,現在鏈接已經失效,但是在arXiv的最新版XGBoost論文中APPENDIX部分有該算法詳細的描述,地址:https://arxiv.org/abs/1603.02754 。現在我們簡單介紹加權分位數略圖侯選點的選取方式,如下:
那么為什么要用二階梯度 進行樣本加權?
我們知道模型的目標函數為:
我們把目標函數配方整理成以下形式,便可以看出 有對 loss 加權的作用。
其中,加入 是因為
和
是上一輪的損失函數求導與
皆為常數。我們可以看到
就是平方損失函數中樣本的權重。
2.2.3 稀疏感知算法
實際工程中一般會出現輸入值稀疏的情況。比如數據的缺失、one-hot編碼都會造成輸入數據稀疏。XGBoost在構建樹的節點過程中只考慮非缺失值的數據遍歷,而為每個節點增加了一個缺省方向,當樣本相應的特征值缺失時,可以被歸類到缺省方向上,最優的缺省方向可以從數據中學到。至於如何學到缺省值的分支,其實很簡單,分別枚舉特征缺省的樣本歸為左右分支后的增益,選擇增益最大的枚舉項即為最優缺省方向。
在構建樹的過程中需要枚舉特征缺失的樣本,乍一看這個算法會多出相當於一倍的計算量,但其實不是的。因為在算法的迭代中只考慮了非缺失值數據的遍歷,缺失值數據直接被分配到左右節點,所需要遍歷的樣本量大大減小。作者通過在Allstate-10K數據集上進行了實驗,從結果可以看到稀疏算法比普通算法在處理數據上快了超過50倍。
3. XGBoost的工程實現
3.1 列塊並行學習
在樹生成過程中,最耗時的一個步驟就是在每次尋找最佳分裂點時都需要對特征的值進行排序。而 XGBoost 在訓練之前會根據特征對數據進行排序,然后保存到塊結構中,並在每個塊結構中都采用了稀疏矩陣存儲格式(Compressed Sparse Columns Format,CSC)進行存儲,后面的訓練過程中會重復地使用塊結構,可以大大減小計算量。
作者提出通過按特征進行分塊並排序,在塊里面保存排序后的特征值及對應樣本的引用,以便於獲取樣本的一階、二階導數值。具體方式如圖:
通過順序訪問排序后的塊遍歷樣本特征的特征值,方便進行切分點的查找。此外分塊存儲后多個特征之間互不干涉,可以使用多線程同時對不同的特征進行切分點查找,即特征的並行化處理。在對節點進行分裂時需要選擇增益最大的特征作為分裂,這時各個特征的增益計算可以同時進行,這也是 XGBoost 能夠實現分布式或者多線程計算的原因。
3.2 緩存訪問
列塊並行學習的設計可以減少節點分裂時的計算量,在順序訪問特征值時,訪問的是一塊連續的內存空間,但通過特征值持有的索引(樣本索引)訪問樣本獲取一階、二階導數時,這個訪問操作訪問的內存空間並不連續,這樣可能造成cpu緩存命中率低,影響算法效率。
為了解決緩存命中率低的問題,XGBoost 提出了緩存訪問算法:為每個線程分配一個連續的緩存區,將需要的梯度信息存放在緩沖區中,這樣就實現了非連續空間到連續空間的轉換,提高了算法效率。此外適當調整塊大小,也可以有助於緩存優化。
3.3 “核外”塊計算
當數據量非常大時,我們不能把所有的數據都加載到內存中。那么就必須將一部分需要加載進內存的數據先存放在硬盤中,當需要時再加載進內存。這樣操作具有很明顯的瓶頸,即硬盤的IO操作速度遠遠低於內存的處理速度,肯定會存在大量等待硬盤IO操作的情況。針對這個問題作者提出了“核外”計算的優化方法。具體操作為,將數據集分成多個塊存放在硬盤中,使用一個獨立的線程專門從硬盤讀取數據,加載到內存中,這樣算法在內存中處理數據就可以和從硬盤讀取數據同時進行。此外,XGBoost 還用了兩種方法來降低硬盤讀寫的開銷:
- 塊壓縮(Block Compression)。論文使用的是按列進行壓縮,讀取的時候用另外的線程解壓。對於行索引,只保存第一個索引值,然后用16位的整數保存與該block第一個索引的差值。作者通過測試在block設置為
個樣本大小時,壓縮比率幾乎達到26%
29%。
- 塊分區(Block Sharding )。塊分區是將特征block分區存放在不同的硬盤上,以此來增加硬盤IO的吞吐量。
4. XGBoost的優缺點
4.1 優點
- 精度更高:GBDT 只用到一階泰勒展開,而 XGBoost 對損失函數進行了二階泰勒展開。XGBoost 引入二階導一方面是為了增加精度,另一方面也是為了能夠自定義損失函數,二階泰勒展開可以近似大量損失函數;
- 靈活性更強:GBDT 以 CART 作為基分類器,XGBoost 不僅支持 CART 還支持線性分類器,使用線性分類器的 XGBoost 相當於帶 L1 和 L2 正則化項的邏輯斯蒂回歸(分類問題)或者線性回歸(回歸問題)。此外,XGBoost 工具支持自定義損失函數,只需函數支持一階和二階求導;
- 正則化:XGBoost 在目標函數中加入了正則項,用於控制模型的復雜度。正則項里包含了樹的葉子節點個數、葉子節點權重的 L2 范式。正則項降低了模型的方差,使學習出來的模型更加簡單,有助於防止過擬合,這也是XGBoost優於傳統GBDT的一個特性。
- Shrinkage(縮減):相當於學習速率。XGBoost 在進行完一次迭代后,會將葉子節點的權重乘上該系數,主要是為了削弱每棵樹的影響,讓后面有更大的學習空間。傳統GBDT的實現也有學習速率;
- 列抽樣:XGBoost 借鑒了隨機森林的做法,支持列抽樣,不僅能降低過擬合,還能減少計算。這也是XGBoost異於傳統GBDT的一個特性;
- 缺失值處理:對於特征的值有缺失的樣本,XGBoost 采用的稀疏感知算法可以自動學習出它的分裂方向;
- XGBoost工具支持並行:boosting不是一種串行的結構嗎?怎么並行的?注意XGBoost的並行不是tree粒度的並行,XGBoost也是一次迭代完才能進行下一次迭代的(第t次迭代的代價函數里包含了前面t-1次迭代的預測值)。XGBoost的並行是在特征粒度上的。我們知道,決策樹的學習最耗時的一個步驟就是對特征的值進行排序(因為要確定最佳分割點),XGBoost在訓練之前,預先對數據進行了排序,然后保存為block結構,后面的迭代中重復地使用這個結構,大大減小計算量。這個block結構也使得並行成為了可能,在進行節點的分裂時,需要計算每個特征的增益,最終選增益最大的那個特征去做分裂,那么各個特征的增益計算就可以開多線程進行。
- 可並行的近似算法:樹節點在進行分裂時,我們需要計算每個特征的每個分割點對應的增益,即用貪心法枚舉所有可能的分割點。當數據無法一次載入內存或者在分布式情況下,貪心算法效率就會變得很低,所以XGBoost還提出了一種可並行的近似算法,用於高效地生成候選的分割點。
4.2 缺點
- 雖然利用預排序和近似算法可以降低尋找最佳分裂點的計算量,但在節點分裂過程中仍需要遍歷數據集;
- 預排序過程的空間復雜度過高,不僅需要存儲特征值,還需要存儲特征對應樣本的梯度統計值的索引,相當於消耗了兩倍的內存。