该篇主要介绍spark版本的xgboost使用方法以及xgboost+lr级联训练的代码和注意点。另外一篇简介了xgboost的模型特征权重输出、负采样校准、特征挖掘实践的内容
小生酸菜鱼:Xgboost工程实践(二)zhuanlan.zhihu.com一. 从Spark任务开始
经过c++模块xfea处理后的训练数据以libsvm格式存储在hdfs上,我们只需几步操作就能跑一个基础的xgboost模型(xgboost接lr做级联训练时,会有个叶子节点onehot_encoder的过程,这个过程在spark中实现有个隐藏问题需要注意,见文末):
- 加载数据:
// 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很方便(但是我没用,因为我自定义了几个评价指标,我想把它们在不同参数组合下的结果都记录下来并观察分析),这里我简单交代下代码和关键点:
- 声明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_weight
和gamma
; - 增加随机性,提高模型对噪声的robust:
subsample
和colsample_bytree
(样本采样和特征采样);
- 降低模型复杂度:
- 如果降低eta(学习率),那一定要增加
num_round
(充分训练);
三. 自定义评价指标
我们常常需要根据自己面对的场景自定义评估指标,以点击率预估模型为例,如果我们需要将模型的预测值用在广告出价上:
- 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版本代码的长度(也可能是我的实现版本太弱鸡了),其间的问题是这样:
上图中有两棵子树,第一棵子树三个叶子节点、第二棵两个叶子节点,假设我们的模型对一条样本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版本保持一致。我们其他场景使用只要保证输入输出符合既定格式就行了,不一定像我这么麻烦。