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