The purpose of this Vignette is to show you how to correctly load and work with an XGBoost model that has been dumped to JSON. XGBoost internally converts all data to 32-bit floats, and the values dumped to JSON are decimal representations of these values. When working with a model that has been parsed from a JSON file, care must be taken to correctly treat:
For the purpose of this tutorial we will load the xgboost, jsonlite,
and float packages. We’ll also set digits=22
in our options
in case we want to inspect many digits of our results.
require(xgboost)
require(jsonlite)
## Loading required package: jsonlite
require(float)
## Loading required package: float
options(digits=22)
We will create a toy binary logistic model based on the example first provided here, so that we can easily understand the structure of the dumped JSON model object. This will allow us to understand where discrepancies can occur and how they should be handled.
<- c(20180130, 20180130, 20180130,
dates 20180130, 20180130, 20180130,
20180131, 20180131, 20180131,
20180131, 20180131, 20180131,
20180131, 20180131, 20180131,
20180134, 20180134, 20180134)
<- c(1, 1, 1,
labels 1, 1, 1,
0, 0, 0,
0, 0, 0,
0, 0, 0,
0, 0, 0)
<- data.frame(dates = dates, labels=labels)
data
<- xgboost(
bst data = as.matrix(data$dates),
label = labels,
nthread = 2,
nrounds = 1,
objective = "binary:logistic",
missing = NA,
max_depth = 1
)
## [1] train-logloss:0.505253
We will now dump the model to JSON and attempt to illustrate a variety of issues that can arise, and how to properly deal with them.
First let’s dump the model to JSON:
<- xgb.dump(bst, with_stats = FALSE, dump_format='json')
bst_json <- fromJSON(bst_json, simplifyDataFrame = FALSE)
bst_from_json <- bst_from_json[[1]]
node cat(bst_json)
## [
## { "nodeid": 0, "depth": 0, "split": "f0", "split_condition": 20180132, "yes": 1, "no": 2, "missing": 1 , "children": [
## { "nodeid": 1, "leaf": 0.360000014 },
## { "nodeid": 2, "leaf": -0.450000018 }
## ]}
## ]
The tree JSON shown by the above code-chunk tells us that if the data is less than 20180132, the tree will output the value in the first leaf. Otherwise it will output the value in the second leaf. Let’s try to reproduce this manually with the data we have and confirm that it matches the model predictions we’ve already calculated.
<- predict(bst,as.matrix(data$dates), outputmargin = TRUE)
bst_preds_logodds
# calculate the logodds values using the JSON representation
<- ifelse(data$dates<node$split_condition,
bst_from_json_logodds $children[[1]]$leaf,
node$children[[2]]$leaf)
node
bst_preds_logodds
## [1] 0.3600000143051147460938 0.3600000143051147460938
## [3] 0.3600000143051147460938 0.3600000143051147460938
## [5] 0.3600000143051147460938 0.3600000143051147460938
## [7] -0.4500000178813934326172 -0.4500000178813934326172
## [9] -0.4500000178813934326172 -0.4500000178813934326172
## [11] -0.4500000178813934326172 -0.4500000178813934326172
## [13] -0.4500000178813934326172 -0.4500000178813934326172
## [15] -0.4500000178813934326172 -0.4500000178813934326172
## [17] -0.4500000178813934326172 -0.4500000178813934326172
bst_from_json_logodds
## [1] 0.3600000139999999793083 0.3600000139999999793083
## [3] 0.3600000139999999793083 0.3600000139999999793083
## [5] 0.3600000139999999793083 0.3600000139999999793083
## [7] 0.3600000139999999793083 0.3600000139999999793083
## [9] 0.3600000139999999793083 0.3600000139999999793083
## [11] 0.3600000139999999793083 0.3600000139999999793083
## [13] 0.3600000139999999793083 0.3600000139999999793083
## [15] 0.3600000139999999793083 -0.4500000180000000016278
## [17] -0.4500000180000000016278 -0.4500000180000000016278
# test that values are equal
== bst_from_json_logodds bst_preds_logodds
## [1] FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE
## [13] FALSE FALSE FALSE FALSE FALSE FALSE
None are equal. What happened?
At this stage two things happened:
When working with imported JSON, all data must be converted to 32-bit floats
To explain this, let’s repeat the comparison and round to two decimals:
round(bst_preds_logodds,2) == round(bst_from_json_logodds,2)
## [1] TRUE TRUE TRUE TRUE TRUE TRUE FALSE FALSE FALSE FALSE FALSE FALSE
## [13] FALSE FALSE FALSE TRUE TRUE TRUE
If we round to two decimals, we see that only the elements related to
data values of 20180131
don’t agree. If we convert the data
to floats, they agree:
# now convert the dates to floats first
<- ifelse(fl(data$dates)<node$split_condition,
bst_from_json_logodds $children[[1]]$leaf,
node$children[[2]]$leaf)
node
# test that values are equal
round(bst_preds_logodds,2) == round(bst_from_json_logodds,2)
## [1] TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE
## [16] TRUE TRUE TRUE
What’s the lesson? If we are going to work with an imported JSON model, any data must be converted to floats first. In this case, since ‘20180131’ cannot be represented as a 32-bit float, it is rounded up to 20180132, as shown here:
fl(20180131)
## # A float32 vector: 1
## [1] 20180132
All JSON parameters stored as floats must be converted to floats.
Let’s now say we do care about numbers past the first two decimals.
# test that values are equal
== bst_from_json_logodds bst_preds_logodds
## [1] FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE
## [13] FALSE FALSE FALSE FALSE FALSE FALSE
None are exactly equal. What happened? Although we’ve converted the data to 32-bit floats, we also need to convert the JSON parameters to 32-bit floats. Let’s do this:
# now convert the dates to floats first
<- ifelse(fl(data$dates)<fl(node$split_condition),
bst_from_json_logodds as.numeric(fl(node$children[[1]]$leaf)),
as.numeric(fl(node$children[[2]]$leaf)))
# test that values are equal
== bst_from_json_logodds bst_preds_logodds
## [1] TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE
## [16] TRUE TRUE TRUE
All equal. What’s the lesson? If we are going to work with an imported JSON model, any JSON parameters that were stored as floats must also be converted to floats first.
Always use 32-bit numbers and operators
We were able to get the log-odds to agree, so now let’s manually calculate the sigmoid of the log-odds. This should agree with the xgboost predictions.
<- predict(bst,as.matrix(data$dates))
bst_preds
# calculate the predictions casting doubles to floats
<- ifelse(fl(data$dates)<fl(node$split_condition),
bst_from_json_preds as.numeric(1/(1+exp(-1*fl(node$children[[1]]$leaf)))),
as.numeric(1/(1+exp(-1*fl(node$children[[2]]$leaf))))
)
# test that values are equal
== bst_from_json_preds bst_preds
## [1] FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE
## [13] FALSE FALSE FALSE FALSE FALSE FALSE
None are exactly equal again. What is going on here? Well, since we
are using the value 1
in the calculations, we have
introduced a double into the calculation. Because of this, all float
values are promoted to 64-bit doubles and the 64-bit version of the
exponentiation operator exp
is also used. On the other
hand, xgboost uses the 32-bit version of the exponentiation operator in
its sigmoid
function.
How do we fix this? We have to ensure we use the correct data types everywhere and the correct operators. If we use only floats, the float library that we have loaded will ensure the 32-bit float exponentiation operator is applied.
# calculate the predictions casting doubles to floats
<- ifelse(fl(data$dates)<fl(node$split_condition),
bst_from_json_preds as.numeric(fl(1)/(fl(1)+exp(fl(-1)*fl(node$children[[1]]$leaf)))),
as.numeric(fl(1)/(fl(1)+exp(fl(-1)*fl(node$children[[2]]$leaf))))
)
# test that values are equal
== bst_from_json_preds bst_preds
## [1] TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE TRUE
## [16] TRUE TRUE TRUE
All equal. What’s the lesson? We have to ensure that all calculations are done with 32-bit floating point operators if we want to reproduce the results that we see with xgboost.