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)