Xgboost: [R] El mejor índice de iteración de la detención anticipada se descarta cuando el modelo se guarda en el disco

Creado en 15 ene. 2020  ·  33Comentarios  ·  Fuente: dmlc/xgboost

Estos valores se predicen después de xgboost::xgb.train :
247367.2 258693.3 149572.2 201675.8 250493.9 292349.2 414828.0 296503.2 260851.9 190413.3

Estos valores se predicen después de xgboost::xgb.save y xgboost::xgb.load del modelo anterior:
247508.8 258658.2 149252.1 201692.6 250458.1 292313.4 414787.2 296462.5 260879.0 190430.1

Están cerca, pero no son iguales. Las diferencias entre estas dos predicciones van desde -1317.094 a 1088.859 en un conjunto de 25.000 muestras. Cuando se compara con etiquetas verdaderas, entonces el MAE / RMSE de estas dos predicciones no difieren mucho.

Entonces sospecho que esto tiene que ver con errores de redondeo durante la carga / guardado, ya que MAE / RMSE no difieren tanto. Aún así, encuentro esto extraño ya que el almacenamiento binario del modelo no debería introducir errores de redondeo.

¿Alguien tiene una pista?

PD Cargar y documentar el proceso de formación no me parece importante aquí. Podría proporcionar detalles si es necesario, o hacer una simulación con datos ficticios para probar el punto.

Blocking bug

Comentario más útil

Misterio resuelto. Identifiqué la verdadera causa. Cuando el modelo se guarda en el disco, la información sobre la detención anticipada se descarta. En el ejemplo, XGBoost ejecuta 6381 rondas de impulso y encuentra el mejor modelo en 6378 rondas. El objeto modelo en la memoria contiene 6381 árboles, no 6378 árboles, ya que no se elimina ningún árbol. Hay un campo adicional best_iteration que recuerda qué iteración fue la mejor:

> fit$best_iteration
[1] 6378

Este campo adicional se descarta silenciosamente cuando guardamos el modelo en el disco. Entonces predict() con el modelo original usa 6378 árboles, mientras que predict() con el modelo recuperado usa 6381 árboles.

> x <- predict(fit, newdata = dtrain2, predleaf = TRUE)
> x2 <- predict(fit.loaded, newdata = dtrain2, predleaf = TRUE)
> dim(x)
[1] 5000 6378
> dim(x2)
[1] 5000 6381

Todos 33 comentarios

No debería haber ningún error de redondeo tanto para binario como para json. ¿Estás usando dardos?

No, no lo soy:

params <- list(objective = 'reg:squarederror',
               max_depth = 10, eta = 0.02, subsammple = 0.5,
               base_score = median(xgboost::getinfo(xgb.train, 'label'))
)

xgboost::xgb.train(
  params = params, data = xgb.train,
  watchlist = list('train' = xgb.train, 'test' = xgb.test),
  nrounds = 10000, verbose = TRUE, print_every_n = 25,
  eval_metric = 'mae',
  early_stopping_rounds = 3, maximize = FALSE)

¿Puede proporcionarnos datos ficticios sobre dónde se produce este fenómeno?

Aquí tienes (Rápido y sucio):

N <- 100000
set.seed(2020)
X <- data.frame('X1' = rnorm(N), 'X2' = runif(N), 'X3' = rpois(N, lambda = 1))
Y <- with(X, X1 + X2 - X3 + X1*X2^2 - ifelse(X1 > 0, 2, X3))

params <- list(objective = 'reg:squarederror',
               max_depth = 5, eta = 0.02, subsammple = 0.5,
               base_score = median(Y)
)

dtrain <- xgboost::xgb.DMatrix(data = data.matrix(X), label = Y)

fit <- xgboost::xgb.train(
  params = params, data = dtrain,
  watchlist = list('train' = dtrain),
  nrounds = 10000, verbose = TRUE, print_every_n = 25,
  eval_metric = 'mae',
  early_stopping_rounds = 3, maximize = FALSE
)

pred <- stats::predict(fit, newdata = dtrain)

xgboost::xgb.save(fit, 'booster.raw')
fit.loaded <- xgboost::xgb.load('booster.raw')

pred.loaded <- stats::predict(fit.loaded, newdata = dtrain)

identical(pred, pred.loaded)
pred[1:10]
pred.loaded[1:10]

sqrt(mean((Y - pred)^2))
sqrt(mean((Y - pred.loaded)^2))

En mi máquina, identical(pred, pred.loaded) es FALSO (es decir, debería ser VERDADERO). Aquí está la salida de los últimos comandos:

> identical(pred, pred.loaded)
[1] FALSE
> pred[1:10]
 [1] -4.7971768 -2.5070562 -0.8889422 -4.9199696 -4.4374819 -0.2739395 -0.9825708  0.4579227  1.3667605 -4.3333349
> pred.loaded[1:10]
 [1] -4.7971768 -2.5070562 -0.8889424 -4.9199696 -4.4373770 -0.2739397 -0.9825710  0.4579227  1.3667605 -4.3333349
> 
> sqrt(mean((Y - pred)^2))
[1] 0.02890702
> sqrt(mean((Y - pred.loaded)^2))
[1] 0.02890565

Ves que las predicciones a veces difieren ligeramente. ¿Puede volver a ejecutar el código de ejemplo en su máquina y ver si tiene el mismo problema?

Alguna información adicional sobre R y xgboost:

> sessionInfo()
R version 3.6.1 (2019-07-05)
Platform: x86_64-w64-mingw32/x64 (64-bit)
Running under: Windows >= 8 x64 (build 9200)

Matrix products: default

locale:
[1] LC_COLLATE=English_United States.1252  LC_CTYPE=English_United States.1252    LC_MONETARY=English_United States.1252 LC_NUMERIC=C                          
[5] LC_TIME=English_United States.1252    

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

loaded via a namespace (and not attached):
 [1] compiler_3.6.1    magrittr_1.5      Matrix_1.2-17     tools_3.6.1       yaml_2.2.0        xgboost_0.90.0.2  stringi_1.4.3     grid_3.6.1       
 [9] data.table_1.12.4 lattice_0.20-38 

También tenga en cuenta que:

> identical(fit$raw, fit.loaded$raw)
[1] TRUE

Gracias por el guion. Solo una actualización, la ejecuté guardando tanto en json como en un archivo binario con:

xgboost::xgb.save(fit, 'booster.json')
fit.loaded <- xgboost::xgb.load('booster.json')

xgboost::xgb.save(fit.loaded, 'booster-1.json')

Los valores hash (via sha256sum ./booster.json ) de booster.json y booster-1.json son exactamente iguales, así que supongo que hay una discrepancia causada por la aritmética de punto flotante.

¿Por qué cerrar el problema sin conocer la causa?

@trivialfis ¿ identical(pred, pred.loaded) ? El OP pregunta por qué las predicciones no coinciden, a pesar de que dos modelos tienen la misma firma binaria.

Intentaré reproducirlo yo mismo.

Oh, lo siento. La causa que encontré es el caché de predicciones. Después de cargar el modelo, los valores de predicción provienen de una predicción verdadera, en lugar de un valor almacenado en caché:

así que supongo que en algún lugar hay una discrepancia causada por la aritmética de punto flotante.

Entonces, ¿la caché de predicción interactúa con la aritmética de punto flotante de una manera destructiva?

@ hcho3 Es un problema que encontré durante la implementación del nuevo método de decapado. Creo que juega un papel importante aquí. Así que primero reduzca la cantidad de árboles a 1000 (que sigue siendo bastante grande y debería ser suficiente para la demostración).

Vuelva a construir DMatrix antes de la predicción para sacar el caché del camino:

dtrain_2 <- xgboost::xgb.DMatrix(data = data.matrix(X), label = Y)

pred <- stats::predict(fit, newdata = dtrain_2)

Pasará la prueba identical . De lo contrario, falla.

Obtener en más árboles, la prueba idéntica todavía tiene pequeñas diferencias (1e-7 para 2000 árboles). Pero, ¿necesitamos producir un resultado idéntico bit a bit incluso en un entorno de subprocesos múltiples?

Como la suma de coma flotante no es asociativa, podemos hacerla como un elemento pendiente para tener una garantía sólida para el orden de cálculo, si así lo desea.

En realidad, hacer una garantía sólida para el pedido no funcionará (ayudará mucho, pero aún habrá discrepancias). Un punto flotante en el registro de la CPU FPU puede tener una mayor precisión y luego almacenarse en la memoria. (La implementación de hardware puede utilizar una mayor precisión para los valores intermedios, https://en.wikipedia.org/wiki/Extended_precision). Mi punto es que cuando el resultado de 1000 árboles es exactamente reproducible dentro de un flotador de 32 bits, es poco probable que se trate de un error de programación.

Estoy de acuerdo en que la suma de coma flotante no es asociativa. Ejecutaré el script yo mismo y veré si la diferencia es lo suficientemente pequeña como para atribuirla a la aritmética de punto flotante.

En general, suelo usar np.testing.assert_almost_equal con decimal=5 para probar si dos matrices flotantes son casi iguales entre sí.

Sí. Disculpas por cerrar sin notas detalladas.

@ hcho3 ¿ Alguna actualización?

Todavía no lo he solucionado. Déjame echarle un vistazo esta semana.

@trivialfis Logré reproducir el error. Ejecuté el script proporcionado y obtuve FALSE por identical(pred, pred.loaded) . Intenté crear una nueva DMatrix dtrain_2 como sugirió y todavía obtuve FALSE para la prueba.

Salida del script de

[1] FALSE     # identical(pred, pred.loaded)
 [1] -4.7760534 -2.5083885 -0.8860036 -4.9163256 -4.4455137 -0.2548684
 [7] -0.9745615  0.4646015  1.3602829 -4.3288369     # pred[1:10]
 [1] -4.7760534 -2.5083888 -0.8860038 -4.9163256 -4.4454765 -0.2548686
 [7] -0.9745617  0.4646015  1.3602829 -4.3288369     # pred.loaded[1:10]
[1] 0.02456085   # MSE on pred
[1] 0.02455945   # MSE on pred.loaded

Salida del script modificado, con dtrain_2 <- xgboost::xgb.DMatrix(data = data.matrix(X), label = Y) :

[1] FALSE     # identical(pred, pred.loaded)
 [1] -4.7760534 -2.5083885 -0.8860036 -4.9163256 -4.4455137 -0.2548684
 [7] -0.9745615  0.4646015  1.3602829 -4.3288369     # pred[1:10]
 [1] -4.7760534 -2.5083888 -0.8860038 -4.9163256 -4.4454765 -0.2548686
 [7] -0.9745617  0.4646015  1.3602829 -4.3288369     # pred.loaded[1:10]
[1] 0.02456085   # MSE on pred
[1] 0.02455945   # MSE on pred.loaded

Entonces, algo más debe estar sucediendo.

También intenté ejecutar una prueba de ida y vuelta:

xgboost::xgb.save(fit, 'booster.raw')
fit.loaded <- xgboost::xgb.load('booster.raw')
xgboost::xgb.save(fit.loaded, 'booster.raw.roundtrip')

y los dos archivos binarios booster.raw y booster.raw.roundtrip eran idénticos.

La diferencia máxima entre pred y pred.loaded es 0.0008370876.

Un ejemplo más pequeño que se ejecuta más rápido:

library(xgboost)

N <- 5000
set.seed(2020)
X <- data.frame('X1' = rnorm(N), 'X2' = runif(N), 'X3' = rpois(N, lambda = 1))
Y <- with(X, X1 + X2 - X3 + X1*X2^2 - ifelse(X1 > 0, 2, X3))

params <- list(objective = 'reg:squarederror',
               max_depth = 5, eta = 0.02, subsammple = 0.5,
               base_score = median(Y)
)

dtrain <- xgboost::xgb.DMatrix(data = data.matrix(X), label = Y)

fit <- xgboost::xgb.train(
  params = params, data = dtrain,
  watchlist = list('train' = dtrain),
  nrounds = 10000, verbose = TRUE, print_every_n = 25,
  eval_metric = 'mae',
  early_stopping_rounds = 3, maximize = FALSE
)

pred <- stats::predict(fit, newdata = dtrain)

invisible(xgboost::xgb.save(fit, 'booster.raw'))
fit.loaded <- xgboost::xgb.load('booster.raw')
invisible(xgboost::xgb.save(fit.loaded, 'booster.raw.roundtrip'))

pred.loaded <- stats::predict(fit.loaded, newdata = dtrain)

identical(pred, pred.loaded)
pred[1:10]
pred.loaded[1:10]
max(abs(pred - pred.loaded))

sqrt(mean((Y - pred)^2))
sqrt(mean((Y - pred.loaded)^2))

Producción:

[1] FALSE
 [1] -2.4875379 -0.9452241 -6.9658904 -2.9985323 -4.2192593 -0.8505422
 [7] -0.3928839 -1.6886091 -1.3611379 -3.1278882
 [1] -2.4875379 -0.9452239 -6.9658904 -2.9985323 -4.2192593 -0.8505420
 [7] -0.3928837 -1.6886090 -1.3611377 -3.1278882
[1] 0.0001592636
[1] 0.01370754
[1] 0.01370706

Intenté hacer un viaje de ida y vuelta adicional, y ahora las predicciones no cambian más.

library(xgboost)

N <- 5000
set.seed(2020)
X <- data.frame('X1' = rnorm(N), 'X2' = runif(N), 'X3' = rpois(N, lambda = 1))
Y <- with(X, X1 + X2 - X3 + X1*X2^2 - ifelse(X1 > 0, 2, X3))

params <- list(objective = 'reg:squarederror',
               max_depth = 5, eta = 0.02, subsammple = 0.5,
               base_score = median(Y)
)

dtrain <- xgboost::xgb.DMatrix(data = data.matrix(X), label = Y)

fit <- xgboost::xgb.train(
  params = params, data = dtrain,
  watchlist = list('train' = dtrain),
  nrounds = 10000, verbose = TRUE, print_every_n = 25,
  eval_metric = 'mae',
  early_stopping_rounds = 3, maximize = FALSE
)

pred <- stats::predict(fit, newdata = dtrain)

invisible(xgboost::xgb.save(fit, 'booster.raw'))
fit.loaded <- xgboost::xgb.load('booster.raw')
invisible(xgboost::xgb.save(fit.loaded, 'booster.raw.roundtrip'))
fit.loaded2 <- xgboost::xgb.load('booster.raw.roundtrip')

pred.loaded <- stats::predict(fit.loaded, newdata = dtrain)
pred.loaded2 <- stats::predict(fit.loaded2, newdata = dtrain)

identical(pred, pred.loaded)
identical(pred.loaded, pred.loaded2)
pred[1:10]
pred.loaded[1:10]
pred.loaded2[1:10]
max(abs(pred - pred.loaded))
max(abs(pred.loaded - pred.loaded2))

sqrt(mean((Y - pred)^2))
sqrt(mean((Y - pred.loaded)^2))
sqrt(mean((Y - pred.loaded2)^2))

Resultado:

[1] FALSE
[1] TRUE
 [1] -2.4875379 -0.9452241 -6.9658904 -2.9985323 -4.2192593 -0.8505422
 [7] -0.3928839 -1.6886091 -1.3611379 -3.1278882
 [1] -2.4875379 -0.9452239 -6.9658904 -2.9985323 -4.2192593 -0.8505420
 [7] -0.3928837 -1.6886090 -1.3611377 -3.1278882
 [1] -2.4875379 -0.9452239 -6.9658904 -2.9985323 -4.2192593 -0.8505420
 [7] -0.3928837 -1.6886090 -1.3611377 -3.1278882
[1] 0.0001592636
[1] 0
[1] 0.01370754
[1] 0.01370706
[1] 0.01370706

Entonces, tal vez la caché de predicción sea un problema.

Volví a ejecutar el script con el almacenamiento en caché de predicciones desactivado:

diff --git a/src/predictor/cpu_predictor.cc b/src/predictor/cpu_predictor.cc
index ebc15128..c40309bc 100644
--- a/src/predictor/cpu_predictor.cc
+++ b/src/predictor/cpu_predictor.cc
@@ -259,7 +259,7 @@ class CPUPredictor : public Predictor {
     // delta means {size of forest} * {number of newly accumulated layers}
     uint32_t delta = end_version - beg_version;
     CHECK_LE(delta, model.trees.size());
-    predts->Update(delta);
+    //predts->Update(delta);

     CHECK(out_preds->Size() == output_groups * dmat->Info().num_row_ ||
           out_preds->Size() == dmat->Info().num_row_);

(Deshabilitar el almacenamiento en caché de predicciones da como resultado un entrenamiento muy lento).

Producción:

[1] FALSE
[1] TRUE
 [1] -2.4908853 -0.9507379 -6.9615889 -2.9935317 -4.2165089 -0.8543566
 [7] -0.3940181 -1.6930715 -1.3572118 -3.1403396
 [1] -2.4908853 -0.9507380 -6.9615889 -2.9935317 -4.2165089 -0.8543567
 [7] -0.3940183 -1.6930716 -1.3572119 -3.1403399
 [1] -2.4908853 -0.9507380 -6.9615889 -2.9935317 -4.2165089 -0.8543567
 [7] -0.3940183 -1.6930716 -1.3572119 -3.1403399
[1] 0.0001471043
[1] 0
[1] 0.01284297
[1] 0.01284252
[1] 0.01284252

Entonces, la caché de predicción definitivamente NO es la causa de este error.

Las predicciones de hojas también divergen:

invisible(xgboost::xgb.save(fit, 'booster.raw'))
fit.loaded <- xgboost::xgb.load('booster.raw')
invisible(xgboost::xgb.save(fit.loaded, 'booster.raw.roundtrip'))
fit.loaded2 <- xgboost::xgb.load('booster.raw.roundtrip')

x <- predict(fit, newdata = dtrain2, predleaf = TRUE)
x2 <- predict(fit.loaded, newdata = dtrain2, predleaf = TRUE)
x3 <- predict(fit.loaded2, newdata = dtrain2, predleaf = TRUE)

identical(x, x2)
identical(x2, x3)

Producción:

[1] FALSE
[1] TRUE

Misterio resuelto. Identifiqué la verdadera causa. Cuando el modelo se guarda en el disco, la información sobre la detención anticipada se descarta. En el ejemplo, XGBoost ejecuta 6381 rondas de impulso y encuentra el mejor modelo en 6378 rondas. El objeto modelo en la memoria contiene 6381 árboles, no 6378 árboles, ya que no se elimina ningún árbol. Hay un campo adicional best_iteration que recuerda qué iteración fue la mejor:

> fit$best_iteration
[1] 6378

Este campo adicional se descarta silenciosamente cuando guardamos el modelo en el disco. Entonces predict() con el modelo original usa 6378 árboles, mientras que predict() con el modelo recuperado usa 6381 árboles.

> x <- predict(fit, newdata = dtrain2, predleaf = TRUE)
> x2 <- predict(fit.loaded, newdata = dtrain2, predleaf = TRUE)
> dim(x)
[1] 5000 6378
> dim(x2)
[1] 5000 6381

@trivialfis Me inclino a eliminar árboles físicamente. Si el entrenamiento se detuvo en 6381 rondas y la mejor iteración fue en 6378 rondas, los usuarios esperarán que el modelo final tenga 6378 árboles.

@ hcho3 Creo que es un problema similar en https://github.com/dmlc/xgboost/issues/4052 .

El bset_iteration debe guardarse en Learner::attributes_ , al que se puede acceder a través de xgboost::xgb.attr .

@ hcho3 , buen hallazgo!

Tenga en cuenta también la documentación de xgboost:::predict.xgb.Booster() :

image

Si entiendo correctamente, ¿la documentación no es del todo correcta? Según la documentación, esperaba que la predicción ya usara todos los árboles. Desafortunadamente, no lo había verificado.

@DavorJ Cuando se activa la parada anticipada, predict() usará el campo best_iteration para obtener la predicción.

@trivialfis La situación es peor en el lado de Python, ya que xgb.predict() no usará la información de la parada temprana en absoluto:

import xgboost as xgb
import numpy as np
from sklearn.datasets import load_boston
from sklearn.model_selection import train_test_split

X, y = load_boston(return_X_y=True)

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

dtrain = xgb.DMatrix(X_train, label=y_train)
dtest = xgb.DMatrix(X_test, label=y_test)

params = {'objective': 'reg:squarederror'}

bst = xgb.train(params, dtrain, 100, [(dtrain, 'train'), (dtest, 'test')],
                early_stopping_rounds=5)

x = bst.predict(dtrain, pred_leaf=True)
x2 = bst.predict(dtrain, pred_leaf=True, ntree_limit=bst.best_iteration)
print(x.shape)
print(x2.shape)

pred = bst.predict(dtrain)
pred2 = bst.predict(dtrain, ntree_limit=bst.best_iteration)

print(np.max(np.abs(pred - pred2)))

Producción:

Will train until test-rmse hasn't improved in 5 rounds.
[1]     train-rmse:12.50316     test-rmse:11.92709
...
[25]    train-rmse:0.56720      test-rmse:2.56874
[26]    train-rmse:0.54151      test-rmse:2.56722
[27]    train-rmse:0.51842      test-rmse:2.56124
[28]    train-rmse:0.47489      test-rmse:2.56640
[29]    train-rmse:0.45489      test-rmse:2.58780
[30]    train-rmse:0.43093      test-rmse:2.59385
[31]    train-rmse:0.41865      test-rmse:2.59364
[32]    train-rmse:0.40823      test-rmse:2.59465
Stopping. Best iteration:
[27]    train-rmse:0.51842      test-rmse:2.56124
(404, 33)
(404, 27)
0.81269073

Los usuarios tendrán que recordar buscar bst.best_iteration y pasarlo como argumento ntree_limit al llamar a predict() . Esto es propenso a errores y causa una sorpresa desagradable.

Tenemos dos opciones para solucionarlo:

  1. Elimine físicamente los árboles que hayan pasado de best_iteration .
  2. Retenga la información best_iteration al serializar el modelo y haga que la función predict() use.

@ hcho3 Tengo una idea a medias sobre esto, que también está relacionada con nuestra opción process_type = update y el bosque.

Fondo

Para un breve resumen de los problemas que tenemos con update , si num_boost_round usado con update es menor que el número de árboles ya existentes, se eliminarán aquellos árboles que no se actualicen .

Para una breve introducción de los problemas con el bosque, best_iteration no se aplica al bosque ya que la función predict requiere un número específico de árboles en lugar de iteración, por lo que en Python hay algo llamado best_ntree_limit , lo cual me confunde mucho. Reemplacé explícitamente ntree_limit en inplace_predict con iteration_range para evitar este atributo.

Idea

Quiero agregar un método slice y concat a booster , que extraen los árboles en 2 modelos y concatenan árboles de 2 modelos en 1. Si tenemos estos 2 métodos :

  • base_margin_ ya no es necesario y creo que es más intuitivo para otros usuarios.
  • ntree_limit en la predicción ya no es necesario, simplemente cortamos el modelo y ejecutamos la predicción en los cortes.
  • update es autónomo, solo actualice los árboles en porciones de una sola vez, no num_boost_rounds .

Más lejos

También creo que esto está conectado de alguna manera a árboles de múltiples objetivos. Como si pudiéramos admitir árboles de múltiples clases y múltiples objetivos en el futuro, habrá varias formas de organizar los árboles, como usar output_groups para cada clase, o cada objetivo, emparejándolos con el bosque y la hoja vectorial. ntree_limit no será suficiente.

También # 5531.

Pero la idea es bastante temprana, así que no tenía la confianza para compartirla, ahora que estamos en este tema, tal vez pueda obtener algunas aportaciones al respecto.

Dada la línea de tiempo 1.1, ¿podemos expandir la documentación para aclarar cómo los usuarios necesitan capturar y usar manualmente esta mejor iteración en la predicción?
¿Y agregarlo a los problemas conocidos en las notas de la versión?

@trivialfis suena interesante, siempre y cuando no estemos complicando más los problemas de configuración al hacer esto.

Eliminar los árboles adicionales del modelo como lo sugiere @ hcho3 es atractivo ya que no tenemos que lidiar con las inconsistencias de tener una longitud de modelo real y una longitud de modelo teórico al mismo tiempo.

¿Fue útil esta página
0 / 5 - 0 calificaciones