XGB+LR 模型融合實戰


原理

  說起CTR 預估,邏輯回歸模型(Logistic Regression)是當之無愧的核心和基礎。即便是在深度學習空前流行的今天,LR 模型仍然憑借其良好的數據基礎、可解釋性強、輕量級的訓練部署要求等優勢,擁有大量適用的應用場景。但是(通常但是之前的話都是廢話),LR 的學習能力有限,需要大量特征工程預先分析出有效的特征、特征組合,從而去間接增強 LR 的非線性學習能力。CTR 的一般開發流程分為數據收集、預處理、 構造數據集、特征工程、模型選擇、超參選擇、離線評估、在線 A/B Test 等步驟,最為重要的就是特征工程,我們常說“特征決定了所有算法效果的上限,不同的算法只是去逼近這個上限”。對於 LR 模型來說特征組合更是尤為關鍵,只能依靠人工經驗,耗時耗力同時並不一定會帶來效果提升。所以如何自動發現有效的特征、特征組合,彌補人工經驗不足,縮短 LR 特征實驗周期,一直都是亟需解決的問題。
  在那個深度學習還沒有涉足 CTR 的年代,Facebook 率先嘗試了通過GBDT(Gradient Boost Decision Tree)解決 LR 的特征組合問題的方案,隨后 Kaggle 競賽也有實踐此思路,GBDT 與 LR 融合開始引起了業界關注。我們都知道 GBDT 基於集成學習中的 boosting 思想,每次迭代都在減少殘差的梯度方向新建立一顆決策樹,迭代多少次就會生成多少顆決策樹。那么怎么利用 GBDT 做特征組合呢?
  參考 Facebook 論文中的例子。如下圖所示,GBDT 由兩棵子樹構成,第一個子樹有 3 個葉子節點,一個訓練樣本進來后,先后落入“子樹 1”的第 1 個葉節點中,那么特征向量就是 [1,0,0],落入“子樹 2”的第 2 個葉節點,特征向量為 [0,1],最后連接所有特征向量,形成最終的特征向量 [1,0,0,0,1]。
  我們可以這么理解上述過程:
  1、可以看做是一種有監督的特征編碼,把實值的 vector 轉換成緊湊的二值的 vector。             
  2、從根節點到葉節點的一條路徑,表示的是在特征上的一個特定的規則。葉節點的編號代表了這種規則,表征了樣本中的信息,而且進行了非線性的組合變換。
  3、最后再對葉節點編號組合,相當於學習這些規則的權重。
  GBDT 的思想使其在特征組合方面具有天然優勢,決策樹的路徑可以直接作為 LR 輸入特征使用,省去了人工尋找特征、特征組合的步驟。那為什么是 GBDT+LR 或 XGB+LR?其它基於樹的模型,例如 Random Forest,是不是也可以與 LR 模型融合?答案是肯定的,所有基於樹的模型都可以和 Logistic Regression 分類器組合。至於為什么選擇 GBDT 或 LR,我們回想下 GBDT 的特點:GBDT 前面的樹,特征分裂主要體現對多數樣本有區分度的特征;后面的樹,主要體現的是經過前 N 顆樹,殘差仍然較大的少數樣本。優先選用在整體上有區分度的特征,再選用針對少數樣本有區分度的特征,思路更加合理。
  原理就介紹到這里,下面主要講一下使用 spark 訓練 xgb+lr 模型。

實戰

  首先,XGB 模型和 LR 模型是分開訓練的,我們不需要 XGB 模型的預測結果,只需要對每棵樹葉子節點進行啞編碼后作為 LR 的輸入。
  XGB 的參數如下:
val xgbParam = Map(
  "eta" -> 0.01f,
  "max_depth" -> 8,
  "objective" -> "binary:logistic",
  "subsample" -> 0.8,
  "colsample_bytree" -> 0.8,
  "colsample_bylevel" -> 0.8,
  "grow_policy" -> "lossguide",
  "eval_metric" -> "auc",
  "num_round" -> 300,
  "num_workers" -> 90,
  "nthread" -> 1)
View Code
  訓練 XGB 模型時,可以嘗試更多棵深度較小的樹,實驗證明 AUC 會有提升。考慮到上線后的性能問題,這里只取了 300 棵樹。有一點要着重提一下。我們訓練 XGB 使用的是 Xgboost4j,Xgboost4j 在 0.8 版本之前有預測葉子節點的方法 transformLeaf,0.8 版本之后刪除了該方法,要想在預測階段取到每棵樹的葉子節點結果,需要添加 setLeafPredictionCol,例如:
xgbClassificationModel.setLeafPredictionCol("predLeaf")
View Code

  然后再調用 transform 方法后,就會多出一列葉子節點的結果。該結果存儲的是樣本落入到的每棵樹的葉子節點的索引組成的 array,array 的長度即為樹的個數,訓練 XGB 時設置了 300 棵樹,所以這里 array 的長度也為 300。如下圖:

  現在我們已經拿到了葉子節點的結果,但是由於儲存的是葉子節點的索引,還不能將這個值直接傳給 LR,因為 LR 對連續值不友好,所以下面需要像原理中介紹的那樣,對每棵樹的葉子節點做 onehot。
  我們可以調用 Booster 類中的 getModelDump 方法取到 XGB 模型的完整結構,除葉子節點外,每個節點有 8 個值,一眼看去大概便能明白是啥意思。我們需要關注的是 nodeid 和 children。
  Nodeid 存儲的是節點編碼,其中根節點值為 0,左子節點的 nodeid 值加 1,右子節點的 nodeid 值加 2;children 記錄了當前節點的子節點信息,因為是二叉樹,所以 children 有兩個值,展開之后結果如下。
  葉子節點的結構如下圖,只有 leaf 和 nodeid 兩個 key,nodeid 存儲的即為葉子節點的索引,也就是我們最終要取出來的值。
  了解了 XGB 模型的結構之后,下面的工作就簡單了,因為我們只需要每棵樹的葉子節點索引,所以只要一個遞歸就可以搞定了。示例代碼如下:
private static List<Integer> getLeafs(Map<String, Object> parentMap) {
        List<Integer> leafs = Lists.newArrayList();
        if(parentMap.containsKey("leaf")) {
            leafs.add((Integer) parentMap.get("nodeid"));
        } else {
            List<Map<String, Object>> leafList = (List<Map<String, Object>>) parentMap.get("children");
            for(Map<String, Object> leafMap : leafList) {
                leafs.addAll(getLeafs(leafMap));
            }
        }

        return leafs;
    }
View Code
  然后分別對每棵樹的葉子節點進行啞編碼,使用 OneHotEncoderEstimator 即可。
  輸入到 LR 模型的特征,葉子節點只是其中一部分,還有很多人工特征。上一步我們生成了葉子節點的啞編碼數據,現在要把這部分特征與其他特征進行合並。
上面提到過,XGB 模型將每條樣本的葉子節點預測結果被存儲成了一個 array,所以第一步需要將 array 拆解成多列。這一步很簡單,可以參考下面的代碼:
val attrs = Array.tabulate(10)(n => "tree_" + n)
attrs.zipWithIndex.foreach(x => {
  trainingLeaf = trainingLeaf.withColumn(x._1, $"predLeaf".getItem(x._2).cast(IntegerType))
})
View Code
  取前 10 棵樹展開的示例如下:
  最開始,我們選擇了直接 join 每棵樹的葉子節點啞編碼結果,但是 spark 在進行大量 join 時會報 OOM 的錯誤,300 棵樹根本無法合並。嘗試取前 100 棵樹,雖然沒有報錯,但是生成一天的訓練數據就要兩個小時左右。所以我們換了一個思路,先將葉子節點索引及其對應的啞編碼向量存儲成 key-value 的形式,然后定義一個 udf,直接生成一列,速度直接提升了十幾倍,合並一天的數據只需要十幾分鍾了。珍愛生命,遠離 join!哦不,是遠離 shuffle! 參考代碼如下:
val treeMap:scala.collection.mutable.Map[String,Map[String,org.apache.spark.ml.linalg.Vector]] = scala.collection.mutable.Map()
for(tree_n <- attrs) {
  // 葉子節點啞編碼結果
  var treeDF = spark.read.format("parquet").load(“葉子節點啞編碼文件”)
  treeDF = treeDF.withColumn("trees", struct(col(tree_n), col(tree_n + "_onehot")))
    .agg(collect_list("trees").alias("trees"))
    .withColumn("trees", map_from_entries($"trees"))
  val treeMapTmp = treeDF.select("trees").collect()(0).get(0).asInstanceOf[Map[String, org.apache.spark.ml.linalg.Vector]]
  treeMap.put(tree_n, treeMapTmp)
}

val treeOnehot = udf((treeKey: String, leafVale: String) => {
  treeMap.get(treeKey).get(leafVale)
})

for(tree_n <- attrs) {
  trainingLeaf = trainingLeaf.withColumn(tree_n + "_onehot", treeOnehot(lit(tree_n), col(tree_n)))
}
View Code
  訓練數據保存成稀疏向量就好了,訓練 LR 的部分就沒什么好講的了,和正常訓練一樣。
  至於線上部署,XGB 調用 xgboost4j 的包,已經封裝好了預測葉子節點的方法 predictLeaf。將 LR 模型的特征和權重離線存儲成 key-value 的形式,線上直接取就可以了。因為輸入到 LR 模型的特征都做了 onehot 操作,每個特征離散化后只有一個值為 1,所以,線上部分我們構造的不再是特征值,而是生成每個特征離散化后的 key 值,然后根據這個 key 去緩存或內存里取其對應的權重值加起來再過一道 sigmoid 輸出的即為 CTR 預測值了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM