Xgboost: [R] O melhor índice de iteração da parada antecipada é descartado quando o modelo é salvo no disco

Criado em 15 jan. 2020  ·  33Comentários  ·  Fonte: dmlc/xgboost

Esses valores são previstos após xgboost::xgb.train :
247367.2 258693.3 149572.2 201675.8 250493.9 292349.2 414828.0 296503.2 260851.9 190413.3

Esses valores são previstos após xgboost::xgb.save e xgboost::xgb.load do modelo anterior:
247508.8 258658.2 149252.1 201692.6 250458.1 292313.4 414787.2 296462.5 260879.0 190430.1

Eles estão próximos, mas não iguais. As diferenças entre essas duas previsões variam de -1317.094 a 1088.859 em um conjunto de 25 mil amostras. Ao comparar com rótulos verdadeiros, o MAE / RMSE dessas duas previsões não difere muito.

Portanto, suspeito que isso tenha a ver com erros de arredondamento durante o carregamento / salvamento, uma vez que o MAE / RMSE não diferem tanto. Ainda assim, acho isso estranho, já que o armazenamento binário do modelo não deve apresentar erros de arredondamento.

Alguém tem uma pista?

PS Carregar e documentar o processo de treinamento não parece importante para mim aqui. Eu poderia fornecer detalhes, se necessário, ou fazer uma simulação com dados fictícios para provar o ponto.

Blocking bug

Comentários muito úteis

Mistério resolvido. Eu identifiquei a verdadeira causa. Quando o modelo é salvo no disco, as informações sobre a parada antecipada são descartadas. No exemplo, o XGBoost executa 6381 rodadas de boost e encontra o melhor modelo com 6378 rodadas. O objeto de modelo na memória contém 6381 árvores, não 6378 árvores, uma vez que nenhuma árvore foi removida. Há um campo extra best_iteration que lembra qual iteração foi a melhor:

> fit$best_iteration
[1] 6378

Este campo extra é descartado silenciosamente quando salvamos o modelo no disco. Portanto, predict() com o modelo original usa 6378 árvores, enquanto predict() com o modelo recuperado usa 6381 árvores.

> 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 comentários

Não deve haver nenhum erro de arredondamento para binário ou json. Você está usando dardo?

Não, não estou:

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)

Você pode nos fornecer dados fictícios onde esse fenômeno ocorre?

Aqui está (Quick & Dirty):

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))

Na minha máquina, identical(pred, pred.loaded) é FALSE (ou seja, deve ser TRUE). Aqui está a saída dos ú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

Você vê que as previsões às vezes diferem ligeiramente. Você pode executar novamente o código de exemplo em sua máquina e ver se ele tem o mesmo problema?

Algumas informações extras sobre R e 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 

Observe também que:

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

Obrigado pelo script. Apenas uma atualização, executei salvando em json e arquivo binário com:

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

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

Os valores de hash (via sha256sum ./booster.json ) de booster.json e booster-1.json são exatamente os mesmos, então meu palpite é que em algum lugar há discrepância causada pela aritmética de ponto flutuante.

Por que encerrar o problema sem saber a causa?

@trivialfis Você obteve True para identical(pred, pred.loaded) ? O OP está perguntando por que as previsões não correspondem, embora dois modelos tenham a mesma assinatura binária.

Vou tentar reproduzi-lo sozinho.

Oh, desculpe. A causa que encontrei é o cache de previsão. Depois de carregar o modelo, os valores de predição vêm da predição verdadeira, em vez do valor armazenado em cache:

então, meu palpite é que em algum lugar há discrepância causada pela aritmética de ponto flutuante.

Então, o cache de previsão interage com a aritmética de ponto flutuante de forma destrutiva?

@ hcho3 É um problema que encontrei durante a implementação do novo método de decapagem. Eu acredito que desempenha um papel importante aqui. Portanto, primeiro reduza o número de árvores para 1000 (o que ainda é muito grande e deve ser o suficiente para uma demonstração).

Reconstrua a DMatrix antes da previsão para tirar o cache do caminho:

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

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

Ele passará no teste identical . Caso contrário, ele falha.

Entrando em mais árvores, o teste idêntico ainda tem pequenas diferenças (1e-7 para 2.000 árvores). Mas precisamos produzir resultado idêntico bit a bit, mesmo em ambiente multi-threaded?

Como o somatório de ponto flutuante não é associativo, podemos torná-lo um item a fazer para ter uma garantia forte para a ordem de cálculo, se desejado.

Na verdade, fazer uma garantia forte para o pedido não funcionará (ajudará muito, mas ainda assim haverá discrepância). Um ponto flutuante no registro da CPU FPU pode ter maior precisão do que armazenado de volta na memória. (A implementação de hardware pode usar maior precisão para valores intermediários, https://en.wikipedia.org/wiki/Extended_precision). Meu ponto é que quando o resultado para 1000 árvores são exatamente reproduzíveis dentro de um float de 32 bits, é improvável que seja um bug de programação.

Eu concordo que a soma de ponto flutuante não é associativa. Vou executar o script sozinho e ver se a diferença é pequena o suficiente para atribuir à aritmética de ponto flutuante.

Em geral, eu geralmente uso np.testing.assert_almost_equal com decimal=5 para testar se duas matrizes de float são quase iguais uma à outra.

Sim. Desculpas por fechar sem notas detalhadas.

@ hcho3 Alguma atualização?

Eu não consegui contornar isso ainda. Deixe-me dar uma olhada esta semana.

@trivialfis consegui reproduzir o bug. Eu executei o script fornecido e obtive FALSE para identical(pred, pred.loaded) . Tentei criar uma nova DMatrix dtrain_2 como você sugeriu e ainda tenho FALSE para o teste.

Saída do 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

Saída do script modificado, com 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

Portanto, algo mais deve estar acontecendo.

Também tentei fazer um teste de ida e volta:

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

e os dois arquivos binários booster.raw e booster.raw.roundtrip eram idênticos.

A diferença máxima entre pred e pred.loaded é 0,0008370876.

Um exemplo menor que executa mais 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))

Saída:

[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

Apenas tentei fazer uma viagem extra de ida e volta, e agora as previsões não mudam mais.

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

Então, talvez o cache de previsão seja realmente um problema.

Eu executei novamente o script com o cache de predição desativado:

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_);

(Desativar o cache de predição resulta em um treinamento muito lento.)

Saída:

[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

Portanto, o cache de previsão definitivamente NÃO é a causa desse bug.

As previsões da folha também divergem:

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)

Saída:

[1] FALSE
[1] TRUE

Mistério resolvido. Eu identifiquei a verdadeira causa. Quando o modelo é salvo no disco, as informações sobre a parada antecipada são descartadas. No exemplo, o XGBoost executa 6381 rodadas de boost e encontra o melhor modelo com 6378 rodadas. O objeto de modelo na memória contém 6381 árvores, não 6378 árvores, uma vez que nenhuma árvore foi removida. Há um campo extra best_iteration que lembra qual iteração foi a melhor:

> fit$best_iteration
[1] 6378

Este campo extra é descartado silenciosamente quando salvamos o modelo no disco. Portanto, predict() com o modelo original usa 6378 árvores, enquanto predict() com o modelo recuperado usa 6381 árvores.

> 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 Estou inclinado a remover árvores fisicamente. Se o treinamento parou em 6381 rodadas e a melhor iteração foi em 6378 rodadas, os usuários esperarão que o modelo final tenha 6378 árvores.

@ hcho3 Acho que é um problema semelhante em https://github.com/dmlc/xgboost/issues/4052 .

O bset_iteration deve ser salvo em Learner::attributes_ , que pode ser acessado através de xgboost::xgb.attr .

@ hcho3 , bom achado!

Observe também a documentação de xgboost:::predict.xgb.Booster() :

image

Se bem entendi, a documentação não está totalmente correta? Com base na documentação, esperava que a previsão já usasse todas as árvores. Infelizmente eu não tinha verificado isso.

@DavorJ Quando a parada antecipada é ativada, predict() usará o campo best_iteration para obter a previsão.

@trivialfis A situação é pior do lado do Python, já que xgb.predict() não usará nenhuma informação da parada inicial:

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)))

Saída:

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

Os usuários terão que se lembrar de buscar bst.best_iteration e passá-lo como ntree_limit argumento ao chamar predict() . Isso está sujeito a erros e é uma surpresa desagradável.

Temos duas opções para corrigir:

  1. Exclua fisicamente as árvores que já passaram de best_iteration .
  2. Retenha as informações de best_iteration ao serializar o modelo e faça com que a função predict() as use.

@ hcho3 Tenho uma ideia process_type = update e floresta.

Fundo

Para uma breve recapitulação dos problemas que temos com update , se num_boost_round usado com update for menor que o número de árvores já existentes, aquelas árvores que não são atualizadas serão removidas .

Para uma breve introdução aos problemas com floresta, best_iteration não se aplica à floresta, pois predict função requer um número específico de árvores em vez de iteração, portanto, em Python há algo chamado best_ntree_limit , o que é muito confuso para mim. Substituí explicitamente ntree_limit em inplace_predict por iteration_range para evitar este atributo.

Ideia

Quero adicionar um método slice e um concat a booster , que extraem as árvores em 2 modelos e concatenam as árvores de 2 modelos em 1. Se tivermos esses 2 métodos :

  • base_margin_ não é mais necessário e acredito que seja mais intuitivo para outros usuários.
  • ntree_limit em previsão não é mais necessário, apenas dividimos o modelo e executamos a previsão nas fatias.
  • update processo é independente, apenas atualize as árvores em fatias de uma vez, não num_boost_rounds .

Avançar

Também acredito que isso esteja de alguma forma conectado a árvores de múltiplos alvos. Como se pudéssemos oferecer suporte a árvores multiclasse com múltiplos alvos no futuro, haverá várias maneiras de organizar árvores, como usar output_groups para cada classe, ou cada alvo, emparelhando com floresta e folha de vetor. ntree_limit não vai ser suficiente.

Também # 5531.

Mas a ideia é muito cedo, então não tive confiança para compartilhá-la, agora que estamos falando sobre esse assunto, talvez eu possa obter algumas contribuições sobre isso.

Dada a linha do tempo 1.1, podemos expandir a documentação para esclarecer como os usuários precisam capturar e usar manualmente essa melhor iteração na previsão?
E adicioná-lo aos problemas conhecidos nas notas de lançamento?

@trivialfis parece interessante, contanto que não estejamos complicando ainda mais os problemas de configuração ao fazer isso.

Excluir as árvores extras do modelo, conforme sugerido por @ hcho3, é atraente, pois não temos que lidar com quaisquer inconsistências por ter um comprimento de modelo real e um comprimento de modelo teórico ao mesmo tempo.

Esta página foi útil?
0 / 5 - 0 avaliações