如何排查?首先想到排查方向就是,两种处理方式中输入的字段类型会不会不一致。如果两种输入中字段类型不一致,或者小数精度不同,那结果出现不同就是可解释的了。仔细分析模型的输入,注意到数组中有一个6.666666666666667,是不是它的原因?
一个个Debug仔细比对两侧的输入数据及其字段类型,完全一致。
这就排除了两种方式处理时,字段类型和精度不一致的问题。
第二个排查思路是,XGBoost on Spark按照模型的功能,提供了XGBoostClassifier和XGBoostRegressor两个上层API,这两个上层API在JNI的基础上,加入了很多超参数,封装了很多上层能力。会不会是在这两种封装过程中,新加入的某些超参数对输入结果有着特殊的处理,从而导致结果不一致?
与反馈此问题的同学沟通后得知,其Python代码中设置的超参数与平台设置的完全一致。仔细检查XGBoostClassifier和XGBoostRegressor的源代码,两者对输出结果并没有做任何特殊处理。
再次排除了XGBoost on Spark超参数封装问题。
再一次检查模型的输入,这次的排查思路是,检查一下模型的输入中有没有特殊的数值,比方说,NaN、-1、0等。果然,输入数组中有好几个0出现,会不会是因为缺失值处理的问题?
快速找到两个引擎的源码,
发现两者对缺失值的处理真的不一致!
XGBoost4j中缺失值的处理
XGBoost4j缺失值的处理过程发生在构造DMatrix过程中,默认将0.0f设置为缺失值:
/** * create DMatrix from dense matrix * * @param data data values * @param nrow number of rows * @param ncol number of columns * @throws XGBoostError native error */ publicDMatrix(float[] data, int nrow, int ncol)throws XGBoostError { long[] out = newlong[1];
也就是说,本地Java调用构造DMatrix时,如果不设置缺失值,默认值0被当作缺失值进行处理。而在XGBoost on Spark中,默认NaN会被为缺失值。原来Java引擎和XGBoost on Spark引擎默认的缺失值并不一样。而平台和该同学调用时,都没有设置缺失值,造成两个引擎执行结果不一致的原因,就是因为缺失值不一致!
修改测试代码,在Java引擎代码上设置缺失值为NaN,执行结果为328.1694,与平台计算结果完全一致。
scala
private[feature] def assemble(vv: Any*): Vector = { val indices = ArrayBuilder.make[Int] val values = ArrayBuilder.make[Double] var cur = 0 vv.foreach { case v: Double =>
//0不进行保存 if (v != 0.0) {
indices += cur values += v } cur += 1 case vec: Vector => vec.foreachActive { case(i, v) =>
//0不进行保存 if (v != 0.0) {
indices += cur + i values += v } } cur += vec.size casenull => thrownew SparkException("Values to assemble cannot be null.") caseo => thrownew SparkException(s"$o of type ${o.getClass.getName} is not supported.") } Vectors.sparse(cur, indices.result(), values.result()).compressed }
不占用存储空间的值,也是某种意义上的一种缺失值。SparseVector作为Spark ML中的数组的保存格式,被所有的算法组件使用,包括XGBoost on Spark。而事实上XGBoost on Spark也的确将Sparse Vector中的0值直接当作缺失值进行处理:
scala val instances: RDD[XGBLabeledPoint] = dataset.select( col($(featuresCol)), col($(labelCol)).cast(FloatType), baseMargin.cast(FloatType), weight.cast(FloatType) ).rdd.map { case Row(features: Vector, label: Float, baseMargin: Float, weight: Float) => val (indices, values) = features match {
//SparseVector格式,仅仅将非0的值放入XGBoost计算 case v: SparseVector => (v.indices, v.values.map(_.toFloat))
XGBoost on Spark将SparseVector中的0值作为缺失值为什么会引入不稳定的问题呢?
重点来了,Spark ML中对Vector类型的存储是有优化的,它会自动根据Vector数组中的内容选择是存储为SparseVector,还是DenseVector。也就是说,一个Vector类型的字段,在Spark保存时,同一列会有两种保存格式:SparseVector和DenseVector。而且对于一份数据中的某一列,两种格式是同时存在的,有些行是Sparse表示,有些行是Dense表示。选择使用哪种格式表示通过下述代码计算得到:
scala /** * Returns a vector in either dense or sparse format, whichever uses less storage. */ @Since("2.0.0") def compressed: Vector = { val nnz = numNonzeros // A dense vector needs 8 * size + 8 bytes, while a sparse vector needs 12 * nnz + 20 bytes. if (1.5 * (nnz + 1.0) toSparse } else { toDense } }
在XGBoost on Spark场景下,默认将Float.NaN作为缺失值。如果数据集中的某一行存储结构是DenseVector,实际执行时,该行的缺失值是Float.NaN。而如果数据集中的某一行存储结构是SparseVector,由于XGBoost on Spark仅仅使用了SparseVector中的非0值,也就导致该行数据的缺失值是Float.NaN和0。
也就是说,如果数据集中某一行数据适合存储为DenseVector,则XGBoost处理时,该行的缺失值为Float.NaN。而如果该行数据适合存储为SparseVector,则XGBoost处理时,该行的缺失值为Float.NaN和0。
即,数据集中一部分数据会以Float.NaN和0作为缺失值,另一部分数据会以Float.NaN作为缺失值!
也就是说在XGBoost on Spark中,0值会因为底层数据存储结构的不同,同时会有两种含义,而底层的存储结构是完全由数据集决定的。
因为线上Serving时,只能设置一个缺失值,因此被选为SparseVector格式的测试集,可能会导致线上Serving时,计算结果与期望结果不符。
问题解决
查了一下XGBoost on Spark的最新源码,依然没解决这个问题。
赶紧把这个问题反馈给XGBoost on Spark, 同时修改了我们自己的XGBoost on Spark代码。