XGBoost缺失值引發的問題及其深度分析

640?wx_fmt=png

總第349篇

2019年 第27篇

 背景

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 < total; 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。

640?wx_fmt=png

兩次結果怎麼會不一樣,問題出現在哪裏呢?

 執行結果不一致問題排查歷程

如何排查?首先想到排查方向就是,兩種處理方式中輸入的字段類型會不會不一致。如果兩種輸入中字段類型不一致,或者小數精度不同,那結果出現不同就是可解釋的了。仔細分析模型的輸入,注意到數組中有一個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
   */
  public DMatrix(float[] data, int nrow, int ncol) throws XGBoostError {
    long[] out = new long[1];

    //0.0f作爲missing的值
    XGBoostJNI.checkCall(XGBoostJNI.XGDMatrixCreateFromMat(data, nrow, ncol, 0.0f, out));

    handle = out[0];
  }
XGBoost on Spark中缺失值的處理

而XGBoost on Spark將NaN作爲默認的缺失值。

  scala
  /**
   * @return A tuple of the booster and the metrics used to build training summary
   */
  @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,

      //NaN作爲missing的值
      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,與平臺計算結果完全一致。
    //測試結果中的一行,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[] testInput = new float[input.length];
    for(int i = 0, total = input.length; i < total; i++){
      testInput[i] = new Double(input[i]).floatValue();
    }
    Booster booster = XGBoost.loadModel("${model}");
    //一行,41列
    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存儲示例見下圖:

640?wx_fmt=png

如上圖所示,SparseVector中不保存數組中值爲0的部分,僅僅記錄非0值。因此對於值爲0的位置其實不佔用存儲空間。下述代碼是Spark ML中VectorAssembler的實現代碼,從代碼中可見,如果數值是0,在SparseVector中是不進行記錄的。

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
      case null =>
        throw new SparkException("Values to assemble cannot be null.")
      case o =>
        throw new 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))

        case v: DenseVector => (null, v.values.map(_.toFloat))
      }
      XGBLabeledPoint(label, indices, values, baseMargin = baseMargin, weight = weight)
    }

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) < size) {
      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代碼。

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 values = features match {

        //SparseVector的數據,先轉成Dense
        case v: SparseVector => v.toArray.map(_.toFloat)

        case v: DenseVector => v.values.map(_.toFloat)
      }
      XGBLabeledPoint(label, null, values, baseMargin = baseMargin, weight = weight)
    }
scala
    /**
     * Converts a [[Vector]] to a data point with a dummy label.
     *
     * This is needed for constructing a [[ml.dmlc.xgboost4j.scala.DMatrix]]
     * for prediction.
     */
    def asXGB: XGBLabeledPoint = v match {
      case v: DenseVector =>
        XGBLabeledPoint(0.0f, null, v.values.map(_.toFloat))
      case v: SparseVector =>

        //SparseVector的數據,先轉成Dense
        XGBLabeledPoint(0.0f, null, v.toArray.map(_.toFloat))

    }
問題得到解決,而且用新代碼訓練出來的模型,評價指標還會有些許提升,也算是意外之喜。希望本文對遇到XGBoost缺失值問題的同學能夠有所幫助,也歡迎大家一起交流討論。

作者簡介

兆軍,美團配送事業部算法平臺團隊技術專家。----------  END  ----------  招聘信息

美團配送事業部算法平臺團隊,負責美團一站式大規模機器學習平臺圖靈平臺的建設。圍繞算法整個生命週期,利用可視化拖拽方式定義模型訓練和預測流程,提供強大的模型管理、線上模型預測和特徵服務能力,提供多維立體的AB分流支持和線上效果評估支持。團隊的使命是爲算法相關同學提供統一的,端到端的,一站式自助服務平臺,幫助算法同學降低算法研發複雜度,提升算法迭代效率。

現面向數據工程,數據開發,算法工程,算法應用等領域招聘資深研發工程師/技術專家/方向負責人(機器學習平臺/算法平臺),歡迎有興趣的同學一起加入,簡歷可投遞至:[email protected]註明:美團配送事業部


也許你還想看

機器學習在美團配送系統的實踐:用技術還原真實世界

美團配送系統架構演進實踐

美團配送資金安全治理之對賬體系建設


640?wx_fmt=png

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章