背景 XGBoost模型作为机器学习中的一大“杀器”,被广泛应用于数据科学竞赛和工业领域,XGBoost官方也提供了可运行于各种平台和环境的对应代码,如适用于Spark分布式训练的XGBoost on Spark。然而,在XGBoost on Spark的官方实现中,却存在一个因XGBoost缺失值和Spark稀疏表示机制而带来的不稳定问题。
事情起源于美团内部某机器学习平台使用方同学的反馈,在该平台上训练出的XGBoost模型,使用同一个模型、同一份测试数据,在本地调用( Java引擎 )与平台( Spark引擎 )计算的结果不一致。但是该同学在本地运行两种引擎( Python引擎和Java引擎 )进行测试,两者的执行结果是一致的。因此质疑平台的XGBoost预测结果会不会有问题?
该平台对XGBoost模型进行过多次定向优化,在XGBoost模型测试时,并没有出现过本地调用( Java引擎 )与平台( Spark引擎 )计算结果不一致的情形。而且平台上运行的版本,和该同学本地使用的版本,都来源于Dmlc的官方版本,JNI底层调用的应该是同一份代码,理论上,结果应该是完全一致的,但实际中却不同。
//测试结果中的一行,41列 double [] input = new double []{1 , 2 , 5 , 0 , 0 , 6.666666666666667 , 31.14 , 29.28 , 0 , 1.303333 , 2.8555 , 2.37 , 701 , 463 , 3.989 , 3.85 , 14400.5 , 15.79 , 11.45 , 0.915 , 7.05 , 5.5 , 0.023333 , 0.0365 , 0.0275 , 0.123333 , 0.4645 , 0.12 , 15.082 , 14.48 , 0 , 31.8425 , 29.1 , 7.7325 , 3 , 5.88 , 1.08 , 0 , 0 , 0 , 32 ];//转化为float[] float [] testInput = new float [input.length];for (int i = 0 , total = input.length; i testInput[i] = new Double(input[i]).floatValue(); } //加载模型 Booster booster = XGBoost.loadModel("${model}" ); //转为DMatrix,一行,41列 DMatrix testMat = new DMatrix(testInput, 1 , 41 ); //调用模型 float [][] predicts = booster.predict(testMat);
上述代码在本地执行的结果是333.67892,而平台上执行的结果却是328.1694030761719。
两次结果怎么会不一样,问题出现在哪里呢?
执行结果不一致问题排查历程 如何排查?首先想到排查方向就是,两种处理方式中输入的字段类型会不会不一致。如果两种输入中字段类型不一致,或者小数精度不同,那结果出现不同就是可解释的了。仔细分析模型的输入,注意到数组中有一个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设置为缺失值: public DMatrix (float [] data, int nrow, int ncol) throws XGBoostError { long [] out = new long [1 ]; XGBoostJNI.checkCall(XGBoostJNI.XGDMatrixCreateFromMat(data, nrow, ncol, 0.0f , out)); handle = out[0 ]; }
而XGBoost on Spark将NaN作为默认的缺失值。
scala @throws(classOf[XGBoostError]) def trainDistributed( trainingDataIn: RDD[XGBLabeledPoint], params: Map[String, Any], round: Int , nWorkers: Int , obj: ObjectiveTrait = null , eval: EvalTrait = null , useExternalMemory: Boolean = false , missing: Float = Float .NaN, hasGroup: Boolean = false ): (Booster, Map[String, Array[Float ]]) = { }
也就是说,本地Java调用构造DMatrix时,如果不设置缺失值,默认值0被当作缺失值进行处理。而在XGBoost on Spark中,默认NaN会被为缺失值。原来Java引擎和XGBoost on Spark引擎默认的缺失值并不一样。而平台和该同学调用时,都没有设置缺失值,造成两个引擎执行结果不一致的原因,就是因为缺失值不一致!
修改测试代码,在Java引擎代码上设置缺失值为NaN,执行结果为328.1694,与平台计算结果完全一致。 double [] input = new double []{1 , 2 , 5 , 0 , 0 , 6.666666666666667 , 31.14 , 29.28 , 0 , 1.303333 , 2.8555 , 2.37 , 701 , 463 , 3.989 , 3.85 , 14400.5 , 15.79 , 11.45 , 0.915 , 7.05 , 5.5 , 0.023333 , 0.0365 , 0.0275 , 0.123333 , 0.4645 , 0.12 , 15.082 , 14.48 , 0 , 31.8425 , 29.1 , 7.7325 , 3 , 5.88 , 1.08 , 0 , 0 , 0 , 32 ]; float [] testInput = new float [input.length]; for (int i = 0 , total = input.length; i testInput[i] = new Double(input[i]).floatValue(); } Booster booster = XGBoost.loadModel("${model}" ); DMatrix testMat = new DMatrix(testInput, 1 , 41 , Float.NaN); float [][] predicts = booster.predict(testMat);
XGBoost on Spark源码中缺失值引入的不稳定问题 Spark ML中还有隐藏的缺失值处理逻辑:SparseVector,即稀疏向量。 SparseVector和DenseVector都用于表示一个向量,两者之间仅仅是存储结构的不同。
其中,DenseVector就是普通的Vector存储,按序存储Vector中的每一个值。
而SparseVector是稀疏的表示,用于向量中0值非常多场景下数据的存储。
SparseVector的存储方式是:仅仅记录所有非0值,忽略掉所有0值。具体来说,用一个数组记录所有非0值的位置,另一个数组记录上述位置所对应的数值。有了上述两个数组,再加上当前向量的总长度,即可将原始的数组还原回来。
因此,对于0值非常多的一组数据,SparseVector能大幅节省存储空间。
SparseVector存储示例见下图:
如上图所示,SparseVector中不保存数组中值为0的部分,仅仅记录非0值。因此对于值为0的位置其实不占用存储空间。下述代码是Spark ML中VectorAssembler的实现代码,从代码中可见,如果数值是0,在SparseVector中是不进行记录的。