Проблемы, вызванные отсутствием значений в XGBoost и его углубленным анализом

машинное обучение
Проблемы, вызванные отсутствием значений в XGBoost и его углубленным анализом

1. Предпосылки

Как главный «убийца» в машинном обучении, модель XGBoost широко используется в соревнованиях по науке о данных и в промышленных областях.XGBoost официально предоставляет соответствующие коды, которые могут работать на различных платформах и средах, таких как распределенное обучение Spark.XGBoost на Spark. Однако в официальной реализации XGBoost на Spark существует проблема нестабильности, вызванная отсутствием значений XGBoost и механизмом разреженного представления Spark.

Инцидент возник из-за отзыва одноклассника, который использовал платформу машинного обучения в Meituan.Модель 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.

Чем могут отличаться два результата и в чем проблема?

2. Процесс устранения неполадок с несогласованными результатами выполнения

Как устранить неполадки? Первое, о чем следует подумать, — не будут ли типы полей, введенные в двух методах обработки, несогласованными. Если типы полей в двух входных данных несовместимы или десятичная точность отличается, разница в результатах интерпретируется. Внимательно проанализируйте ввод в модель и обратите внимание, что в массиве есть 6,6666666666666667, это причина?

Один за другим Debug тщательно сравнивает входные данные и типы полей с обеих сторон, которые абсолютно одинаковы.

Это устраняет проблему несовместимости типа поля и точности при обработке двух методов.

Вторая идея устранения неполадок заключается в том, что XGBoost в Spark предоставляет два API верхнего уровня, XGBoostClassifier и XGBoostRegressor, в соответствии с функциями модели.На основе JNI эти два API верхнего уровня добавляют множество гиперпараметров и инкапсулируют множество возможностей верхнего уровня. Может ли быть так, что в двух процессах инкапсуляции некоторые из недавно добавленных гиперпараметров имеют специальную обработку входных результатов, что приводит к противоречивым результатам?

После общения со студентами, сообщившими об этой проблеме, я узнал, что гиперпараметры, установленные в коде Python, точно такие же, как и на платформе. Внимательно проверьте исходный код XGBoostClassifier и XGBoostRegressor, они не выполняют никакой специальной обработки вывода.

Проблема инкапсуляции гиперпараметров XGBoost в 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 на Spark

Принимая во внимание, что xgboost в Spark принимает NaN в качестве отсутствующего значения по умолчанию.

/**
 * @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 на Spark значения NaN по умолчанию считаются отсутствующими. Оказывается, пропущенные значения по умолчанию движка Java и XGBoost на движке Spark не совпадают. Когда платформа и одноклассник вызывают, пропущенное значение не установлено, и причина, по которой результаты выполнения двух движков несовместимы, заключается в том, что пропущенные значения несовместимы!

Измените тестовый код, установите отсутствующее значение NaN в коде движка Java, и результат выполнения будет 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);

3. Проблема нестабильности, вызванная отсутствием значений в XGBoost в исходном коде Spark.

Однако все не так просто.

В Spark ML также есть скрытая логика обработки отсутствующих значений: SparseVector или разреженный вектор.

И SparseVector, и DenseVector используются для представления вектора, и разница между ними заключается только в структуре хранения.

Среди них DenseVector — обычное векторное хранилище, которое хранит каждое значение в Векторе по порядку.

SparseVector — это разреженное представление, которое используется для хранения данных в сценариях, где в векторе много нулевых значений.

Метод хранения SparseVector: записывать только все ненулевые значения, игнорируя все нулевые значения. В частности, используйте один массив для записи позиций всех ненулевых значений, а другой массив — для записи значений, соответствующих указанным выше позициям. С двумя вышеуказанными массивами плюс общая длина текущего вектора можно восстановить исходный массив.

Следовательно, для набора данных с очень большим количеством нулевых значений SparseVector может значительно сэкономить место для хранения.

Пример хранилища SparseVector показан на следующем рисунке:

Как показано на рисунке выше, SparseVector не сохраняет часть со значением 0 в массиве, а записывает только значения, отличные от 0. Таким образом, расположение со значением 0 на самом деле не занимает места для хранения. Следующий код представляет собой код реализации VectorAssembler в Spark ML.Из кода видно, что если значение равно 0, оно не записывается в SparseVector.

    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 в Spark. На самом деле XGBoost в Spark обрабатывает 0 значений в Sparse Vector напрямую как отсутствующие значения:

    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 на Spark обрабатывает 0 значений в SparseVector как отсутствующие значенияЗачем вводить проблемы нестабильности?

Дело в том, что хранение типа Vector в Spark ML оптимизировано, он автоматически выбирает, хранить ли его как SparseVector или DenseVector в соответствии с содержимым массива Vector. То есть, когда поле типа Vector сохраняется в Spark, один и тот же столбец будет иметь два формата сохранения: SparseVector и DenseVector. А для столбца в фрагменте данных одновременно существуют два формата, некоторые строки представлены Sparse, а некоторые строки представлены Dense. Выбор формата для использования рассчитывается с помощью следующего кода:

  /**
   * 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 на Spark Float.NaN используется как отсутствующее значение по умолчанию. Если структура хранения строки в наборе данных — DenseVector, отсутствующее значение строки — Float.NaN при фактическом выполнении. Однако если строка в наборе данных хранится как SparseVector, поскольку XGBoost в Spark использует только ненулевые значения в SparseVector, отсутствующие значения этой строки данных — Float.NaN и 0.

То есть, если строка данных в наборе данных подходит для хранения в виде DenseVector, отсутствующее значение этой строки — Float.NaN, когда ее обрабатывает XGBoost. И если строка данных подходит для хранения в виде SparseVector, отсутствующие значения строки — Float.NaN и 0 при обработке XGBoost.

То есть часть данных в наборе данных будет иметь Float.NaN и 0 в качестве пропущенных значений, а другая часть данных будет иметь Float.NaN в качестве пропущенных значений!То есть в XGBoost на Spark значение 0 будет иметь два значения одновременно из-за разницы в базовой структуре хранения данных, а базовая структура хранения полностью определяется набором данных.

Поскольку, когда в строке обслуживания отсутствует только заданное значение, тестовый набор был выбран в формате SparseVector, это может привести к тому, что в строке обслуживания результаты не будут соответствовать желаемым результатам.

4. Решение проблем

Я проверил последний исходный код XGBoost на Spark, но так и не решил эту проблему.

Быстро сообщите об этой проблеме в XGBoost в Spark и измените наш собственный код XGBoost в Spark.

   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)
    }
    /**
     * 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, и вы можете обменяться мнениями и обсудить их вместе.

об авторе

  • Чжао Цзюнь, технический эксперт группы платформы алгоритмов отдела дистрибуции Meituan.

Предложения о работе

Команда алгоритмической платформы Meituan Distribution Division отвечает за создание платформы Turing, универсальной крупномасштабной платформы машинного обучения Meituan. На протяжении всего жизненного цикла алгоритма процесс обучения и прогнозирования модели определяется методом визуального перетаскивания, обеспечивающим мощное управление моделью, онлайн-прогнозирование модели и возможности обслуживания функций, а также поддержку многомерного шунтирования АБ и онлайн- поддержка оценки эффекта. Миссия команды состоит в том, чтобы предоставить унифицированную сквозную универсальную платформу самообслуживания для студентов, изучающих алгоритмы, чтобы помочь изучающим алгоритмы снизить сложность разработки алгоритмов и повысить эффективность итераций алгоритмов.

В настоящее время мы набираем старших инженеров по исследованиям и разработкам / технических экспертов / директоров (платформа машинного обучения / платформа алгоритмов) для проектирования данных, разработки данных, разработки алгоритмов, применения алгоритмов и других областей. Заинтересованные студенты могут присоединиться к нам. Резюме можно отправить по адресу: tech@meituan.com (Примечание: Дистрибьюторское подразделение Meituan)