原理
說起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)
訓練 XGB 模型時,可以嘗試更多棵深度較小的樹,實驗證明 AUC 會有提升。考慮到上線后的性能問題,這里只取了 300 棵樹。有一點要着重提一下。我們訓練 XGB 使用的是 Xgboost4j,Xgboost4j 在 0.8 版本之前有預測葉子節點的方法 transformLeaf,0.8 版本之后刪除了該方法,要想在預測階段取到每棵樹的葉子節點結果,需要添加 setLeafPredictionCol,例如:
xgbClassificationModel.setLeafPredictionCol("predLeaf")
然后再調用 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; }
然后分別對每棵樹的葉子節點進行啞編碼,使用 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)) })
取前 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))) }
訓練數據保存成稀疏向量就好了,訓練 LR 的部分就沒什么好講的了,和正常訓練一樣。
至於線上部署,XGB 調用 xgboost4j 的包,已經封裝好了預測葉子節點的方法 predictLeaf。將 LR 模型的特征和權重離線存儲成 key-value 的形式,線上直接取就可以了。因為輸入到 LR 模型的特征都做了 onehot 操作,每個特征離散化后只有一個值為 1,所以,線上部分我們構造的不再是特征值,而是生成每個特征離散化后的 key 值,然后根據這個 key 去緩存或內存里取其對應的權重值加起來再過一道 sigmoid 輸出的即為 CTR 預測值了。
