xgboost调参_Xgboost工程实践(一)

1a696db24132c7141977175271ec61cb.png

该篇主要介绍spark版本的xgboost使用方法以及xgboost+lr级联训练的代码和注意点。另外一篇简介了xgboost的模型特征权重输出、负采样校准、特征挖掘实践的内容

小生酸菜鱼:Xgboost工程实践(二)​zhuanlan.zhihu.com
6543b086ca4da4e095567d96f8eb3de4.png

一. 从Spark任务开始

经过c++模块xfea处理后的训练数据以libsvm格式存储在hdfs上,我们只需几步操作就能跑一个基础的xgboost模型(xgboost接lr做级联训练时,会有个叶子节点onehot_encoder的过程,这个过程在spark中实现有个隐藏问题需要注意,见文末):

  1. 加载数据:
// path1 = hdfs://domain/data/xgb_test/example/agaricus.txt.train
var allData: DataFrame = spark.read.format("libsvm").load(path1)

// 如果是多个路径,比如hdfs上以日期分区存储每天的训练数据
for (i <- 1 until pathList.length) {
    data = data.union(spark.read.format("libsvm").load(pathList(i)))
}

通过DataFrame自带的printSchema()函数可以查看,此时加载出来数据已是spark-ml标准训练数据格式:org.apache.spark.sql.DataFrame = [label: double, features: vector]

一般我们会准备三份数据集:训练数据、验证数据、测试数据,这里我是已7天的数据(100GB左右)作为训练+验证集,1天的数据作为测试集:

// 数据量很大之后就没有必要对数据集进行37分或者28分了,大概留一部分验证数据即可
val Array(train, eval) = allData.randomSplit(Array(0.99, 0.01), 123)
var test: DataFrame = spark.read.format("libsvm").load(path8)

其实我们没必要使用过大数据量的,确实徒增训练时长和资源消耗,在Facebook的论文中有d对数据做过采样(uniform subsampling):仅用10%的数据量进行训练模型,normalized entropy也仅下降了1%。我不精确比较过,20GB的数据量跟100GB几乎一样。

2. 参数配置:

// xgboost的参数,这里treeNum定义在外面是留给后面xgb的预测结果转化成lr特征用的
val treeNum = 100
var xgbParam = Map(
    "objective" -> "binary:logistic",
    "num_workers" -> 100,
    "num_round" -> treeNum,
    "max_depth" -> 8,
    "subsample" -> 0.8,
    "colsample_bytree" -> 0.8,
    "min_child_weight" -> 2,
    "scale_pos_weight" -> 1.0,
    "lambda" -> 1,
    "alpha" -> 0,
    "gamma" -> 0.2,
    "eta" -> 0.1
//  "eval_metric" -> "logloss"
 )
1. objective [default: reg:squarederror(均方误差)]
a: 目标函数的选择,默认为均方误差损失,当然还有很多其他的,这里列举几个主要的
b: reg:squarederror 均方误差
c: reg:logistic 对数几率损失,参考对数几率回归(逻辑回归)
d: binary:logistic 二分类对数几率回归,输出概率值
e: binary:hinge 二分类合页损失,此时不输出概率值,而是0或1
f: multi:softmax 多分类softmax损失,此时需要设置num_class参数
2. eval_metric [default: 根据objective而定]
a: 模型性能度量方法,主要根据objective而定,也可以自定义一些,下面列举一些常见的
b: rmse : root mean square error 也就是平方误差和开根号
c: mae : mean absolute error 误差的绝对值再求平均
d: auc : area under curve roc曲线下面积
e: aucpr : area under the pr curve pr曲线下面积
f: logloss: 对数损失

这里的参数已经是调整好的了,后面调参细节再细讲。

3. 开始训练:

val watches = new mutable.HashMap[String, DataFrame]
watches += "eval" -> eval
// watches += "test" -> test

// xgboost 模型
val booster = new XGBoostClassifier(xgbParam)
      .setFeaturesCol("features")
      .setLabelCol("label")
      .setEvalSets(watches.toMap)
      .setCustomEval(new NormalizedEntropy)
    //      .setMaximizeEvaluationMetrics(false)

val xgbModel: XGBoostClassificationModel = booster.fit(train)

/*
 * 模型保存到我们的hdfs上面,二进制文件
 * 拉取到本地只需:hadoop fs -get 命令即可
 * 注意,我在实际使用中用maven库里的xgboost各个版本保存xgboost模型都失败了,
 * 原因是往hdfs是保存模型文件异常,我是通过重新编译xgboost源码:USE_HDFS = 1,获得到可用的jar包,
 * 个中曲折另外再书
*/
xgbModel.nativeBooster.saveModel(xgbModelPath)

这里有三个小点:

  • 指明我们数据集的features列和labels列(有可能我们取其他名字);
  • 我们设置了eval_sets,指定验证数据集;
  • 我们自定义了一个评估指标:NormalizedEntropy(自定义评估指标也在后面细说);

这里的eval_set(评估数据集)和eval_metric(评估指标)是给我们来观测模型在数据集上的表现,以便后续优化调整;

如果设置了early_stopping_rounds,训练过程中会监控eval_metric指标,如果经过early_stopping_rounds轮还没有减少那么就停止训练;

训练时,xgboost框架根据我们的模型类型自动选择目标函数,当然我们也可以自定义Objective;

二. 参数调优

参数调优我们的spark-ml提供的ParamGridBuilder很方便(但是我没用,因为我自定义了几个评价指标,我想把它们在不同参数组合下的结果都记录下来并观察分析),这里我简单交代下代码和关键点:

  1. 声明classifier和评估指标:
// xgboost 模型
val booster = new XGBoostClassifier(xgbParam)

// 使用Evaluator来评价模型的性能,最佳性能的参数的模型做为选择的结果
val evaluator = new BinaryClassificationEvaluator()
    .setLabelCol("label")
    .setRawPredictionCol("probability")
    .setMetricName("areaUnderROC")

这里的几行定义却是最关键的甚至会带来bug让人难受不已:setRawPredictionCol("probability")!(也可以用"rawPrediction")

spark的模型对象去做批量预测时,例如:val xgbPredTe = xgbModel.transform(test),其返回对象也是DataFrame,对应的schema:

root
 |-- label: double (nullable = true)
 |-- features: vector (nullable = true)
 |-- rawPrediction: vector (nullable = true)
 |-- probability: vector (nullable = true)
 |-- prediction: double (nullable = false)

其中rawPrediction是未经sigmoid处理过的、probability是sigmoid函数映射后的概率预估值,而prediction确实按照某阈值(没查,应该是0.5)进行了概率值到label的映射(值为0或1)。

如果我们在上面把评估指标的列写成了prediction,那极可能会出错的,除非正负样本比例相差无几。(比如你可能会看到训练集到测试集的auc都是0.5...)

2. CrossValidator和TrainValidationSplit

// 注意:这里整体寻优计算量太大,建议分步寻优
val xgbParamGrid = new ParamGridBuilder()
    .addGrid(booster.numRound, Array(50, 100, 150))
    .addGrid(booster.maxDepth, 6 until 10)
    .addGrid(booster.eta, 0.05 until (0.21, 0.05))
    .build()

// 以下两个选一个即可
// 1. CrossValidator
val cv = new CrossValidator()
    .setEstimator(booster)
    .setEvaluator(evaluator)
    .setEstimatorParamMaps(xgbParamGrid)
    .setNumFolds(10)

// 2. TrainValidationSplit
val tv = new TrainValidationSplit()
    .setEstimator(booster)
    .setEvaluator(evaluator)
    .setEstimatorParamMaps(xgbParamGrid)
    .setTrainRatio(0.99)
    .setParallelism(4)

// 训练
val gridModel = tv.fit(train)    // cv.fit(train)
val bestModel = gridModel.bestModel.asInstanceOf[XGBoostClassificationModel]
1. CrossValidator
CrossValidator 是Spark提供的一个可以用来做交叉验证的工具。它可以把数据分成若干个集合,用来分别做训练集和验证集。
比如对于对于3折交叉验证来说,就是数据集分成3份,轮流把其中的1/3拿出来做验证集,那么剩下的2/3就是训练集。评价ParamMap中一组参数的好坏,CrossValidator将会计算这组参数在3次不同的fold中的评分均值。
在得到ParamMap中的最佳参数后,CrossValidator 会重新使用这组参数在 整个数据集上来 再次fit得到最终的Model。
2. TrainValidationSplit
Spark同样提供了另一种工具TrainValidationSplit来做模型选择。与上面的CV不同的是TV只做一次数据分割,而不是像CV那样分割成多个fold进行交叉验证。所以TV代价更低,当没有足够多的数据进行交叉验证时,这个工具也可以用来给出不错的模型选择结果。
设置trainRatio,可以用来选择训练集在整个数据的比例,剩下的就是测试集。
就像CV一样,TV也会在选择最佳参数后重新在整个数据集上再次fit得到最终的Model。
作者:shohokuooo
链接: https://www. jianshu.com/p/7e1011b13 5a1
来源:简书

3. 调参指南

如果我们数据量大要看的参数有很多就不要用grid搜索了,时间太漫长了,xgboost调参我参考了这位老师的建议:

3.1) 首先调整max_depth ,通常max_depth 这个参数与其他参数关系不大,初始值设置为10,找到一个最好的误差值,然后就可以调整参数与这个误差值进行对比。比如调整到8,如果此时最好的误差变高了,那么下次就调整到12;如果调整到12,误差值比10 的低,那么下次可以尝试调整到15.
3.2) 在找到了最优的max_depth之后,可以开始调整subsample,初始值设置为1,然后调整到0.8 如果误差值变高,下次就调整到0.9,如果还是变高,就保持为1.0
3.3) 接着开始调整min_child_weight , 方法与上面同理
3.4) 再接着调整colsample_bytree
3.5) 经过上面的调整,已经得到了一组参数,这时调整eta 到0.05,然后让程序运行来得到一个最佳的num_round,(在 误差值开始上升趋势的时候为最佳 )
作者:行路南
链接:https://www.jianshu.com/p/7e1011b135a1
来源:CSDN

另外有几点补充说明:

  • min_child_weight:叶子节点的最小权重阈值,低于它则不继续分裂;如果是回归模型,则其值代表叶子节点样本个数,如果是分类模型,代表的是H(叶子节点的二阶导的和);
  • scale_pos_weight:正样本的权重值,经典设置值:负样本个数/正样本个数。官方调参中说了,如果我们仅考察AUC指标(只关注顺序),那么可以来设置这个值(实际使用中确实AUC提高,但是其他指标下降很厉害),否则不用设置(默认=1.0)
  • 如果出现过拟合了,
    • 降低模型复杂度:max_depth,min_child_weightgamma
    • 增加随机性,提高模型对噪声的robust:subsamplecolsample_bytree(样本采样和特征采样);
  • 如果降低eta(学习率),那一定要增加num_round(充分训练);

三. 自定义评价指标

我们常常需要根据自己面对的场景自定义评估指标,以点击率预估模型为例,如果我们需要将模型的预测值用在广告出价上:

,即要求我们保序且保距(正样本尽量排在负样本前面&&预测用户的点击率值尽量精准,否则我们的出价会偏高,很大概率会导致我们的广告投放成本超出预期)。同样参考的Facebook的论文使用这三个指标:
  • normalized_entropy:
  • calibration:
  • auc:Area-Under-ROC

NE主要是消除不平衡数据集影响,即归一化熵;calibration关注预测结果的准确性,越接近1越好;auc即来考察模型对正负样本的区分能力。

// 这段代码演示,spark任务中如何自定义一个评估指标,传入xgboost进行评估计算,这里以NormalizedEntropy为例

class NormalizedEntropy extends EvalTrait {
  private val logger: Log = LogFactory.getLog(classOf[NormalizedEntropy])
  /**
   * get evaluate metric
   *
   * @return evalMetric
   */
  override def getMetric: String = {
    "normalized_entropy"
  }

  /**
   * evaluate with predicts and data
   *
   * @param predicts  predictions as array
   * @param dmat      data matrix to evaluate
   * @return result of the metric
   */
  override def eval(predicts: Array[Array[Float]], dmat: DMatrix): Float = {
    var labels: Array[Float] = null
    try {
      labels = dmat.getLabel
    } catch {
      case ex: XGBoostError =>
        logger.error(ex)
        return -1f
    }
    val nrow: Int = predicts.length
    var pos_num = 0.0d
    var log_sum = 0.0d
    for (i <- 0 until nrow) {
      pos_num += labels(i)
      log_sum += (labels(i) * log(predicts(i)(0)) + (1 - labels(i)) * log(1 - predicts(i)(0)))
    }
    val num = -1.0d / nrow * log_sum 
    val avg_prob = pos_num / nrow
    val den = -1.0d * (avg_prob * log(avg_prob) + (1 - avg_prob) * log(1 - avg_prob))

    (num / den).toFloat
  }
}

如果在python中更简洁了,直接定义一个函数即可调用

# python以calibration为例
def calibration(preds, eval_data):
    # import xgboost as xgb
    # assert isinstance(eval_data, xgb.core.DMatrix)
    labels = eval_data.get_label()
    return 'calibration', sum(preds)/sum(labels)

自定义评估函数最大作用就在使用early_stop,按照我们的主要考察指标提前停止在最好的状态;如果我们不使用early_stop,我们完全可以写一个函数仅接受真实值序列和预测值序列,自由地计算评估结果。

四. 接LogisticRegression级联训练

这一步也容易,我在实现spark离线分布式训练版本和c++的实时推断接口都是以python版本的结果为标准的,首先看一下python脚本的主要部分:

from sklearn.preprocessing.data import OneHotEncoder

dr_leaves = booster.predict(dtrain, pred_leaf=True)
dt_leaves = booster.predict(dtest, pred_leaf=True)

all_leaves = np.concatenate((dr_leaves, dt_leaves), axis=0)
all_leaves = all_leaves.astype(np.int32)

xgb_enc = OneHotEncoder()
X_trans = xgb_enc.fit_transform(all_leaves)

但是spark-ml提供的onehot_encoder却没这么人性化的简洁,先看一下完整代码,隐藏问题再做细讲:

val xgbModel: XGBoostClassificationModel = booster.fit(train)

// 增加下面transform的一列输出,列名:predictLeaf
xgbModel.setLeafPredictionCol("predictLeaf")

// 此时多出一列,其列schema为:vector<double>,稀疏向量的表示:[ [13,7,....,10], [1.0,1.0,....,1.0] ]
val xgbPredTr = xgbModel.transform(train)

// 获取预测叶子索引,这里的treeNum我们已经提前定义在外面,
// 这一步是将predictLeaf的向量展开成多列,
// 比如100棵子树,那一条预测样本的predictLeaf就是100维的向量,
// 后面的onehot_encoder是针对一列进行转码,
// 所以需要将这里的100维向量给展开成100列
val tmpLeavesTrainDF = xgbPredTr.select(
  col("label") +: col("features") +:
    (0 until treeNum).map(i => col("predictLeaf").getItem(i).as(s"leaf_$i").cast(StringType)): _* // 变长参数
)

// 这一步的StringIndexer就是为了将spark-ml的onehot结果统一成python版本的样子
val leafIndexers = (0 until treeNum).map(i =>s"leaf_$i").map(
  col => {
    new StringIndexer().setInputCol(col).setOutputCol(col + "_idx")
  }
).toArray

val leafIndexersPipe = new Pipeline().setStages(leafIndexers).fit(tmpLeavesTrainDF)
val labelWithLeavesTrainDF = leafIndexersPipe.transform(labelWithLeavesTrainDF)

// xgb模型输出 叶子节点 做onehot转换
val encoder = new OneHotEncoderEstimator()
    .setInputCols((0 until treeNum).map(i => s"leaf_${i}_idx").toArray)
    .setOutputCols((0 until treeNum).map(i => s"onehot_$i").toArray)
    .setDropLast(false)

val mapModel = encoder.fit(labelWithLeavesTrainDF)
val transformedTrainDF = mapModel.transform(labelWithLeavesTrainDF)

// 构造LR模型的输入样本
val vectorAssembler = new VectorAssembler().
    setInputCols((0 until treeNum).map(i => "onehot_" + i).toArray).
    setOutputCol("lrFeatures")

val lrTrainInput = vectorAssembler.transform(transformedTrainDF)
    .select("lrFeatures", "label")

肉眼可见的spark版本代码的长度(也可能是我的实现版本太弱鸡了),其间的问题是这样:

ef8e5ec2cdd001ed415e1b0c20d43b96.png

上图中有两棵子树,第一棵子树三个叶子节点、第二棵两个叶子节点,假设我们的模型对一条样本predictLeaf为:[4, 3](第一个子树落在叶子节点4、第二个在叶子节点3),按照我们的正常理解和python版本的结果,onehot编码结果应该为:[0,1,0,0,1]。但是spark-ml的onehot后结果却为:[0,0,0,1,0,0,0,1]。spark-ml的onehot仅是根据索引值重新编排,并没有真正的fit-transform过程,所以需要我们动用StringIndex这个api将叶子节点值重新index化。(但是这一步也是极其的耗时费资源,正在寻找其他解决方案)

最后提一句,因为我的c++线上推断模块按照python版本的结果格式提前开发好了,所以需要spark版本的结果也跟python版本保持一致。我们其他场景使用只要保证输入输出符合既定格式就行了,不一定像我这么麻烦。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值