Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions onnxmltools/convert/lightgbm/operator_converters/LightGbm.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,9 +554,9 @@ def convert_lightgbm(scope, operator, container):
if "num_class" in gbm_text:
n_classes = gbm_text["num_class"]
if n_classes == 1:
attrs["post_transform"] = "LOGISTIC"
attrs["post_transform"] = "LOGISTIC" # binary → needs sigmoid
else:
attrs["post_transform"] = "NONE"
attrs["post_transform"] = "NONE" # multiclass → already softmax elsewhere
objective = "binary"
Comment thread
JOSH1024 marked this conversation as resolved.
else:
raise NotImplementedError(
Expand Down
43 changes: 38 additions & 5 deletions onnxmltools/convert/xgboost/operator_converters/XGBoost.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,10 +393,11 @@ def convert(scope, operator, container):
)

attr_pairs = XGBRegressorConverter._get_default_tree_attribute_pairs()

if isinstance(base_score, list):
attr_pairs["base_values"] = base_score
bs_list = base_score
else:
attr_pairs["base_values"] = [base_score]
bs_list = [base_score]

if best_ntree_limit and best_ntree_limit < len(js_trees):
js_trees = js_trees[:best_ntree_limit]
Expand All @@ -408,13 +409,45 @@ def convert(scope, operator, container):
params = XGBConverter.get_xgb_params(xgb_node)
attr_pairs["n_targets"] = params["n_targets"]

# binary:logistic: XGBoost accumulates tree outputs in logit space and
# applies sigmoid at the end. base_score is stored in probability space
# so convert it to logit space before passing to TreeEnsembleRegressor,
# then append an explicit Sigmoid node.
if objective == "binary:logistic":
bs_val = np.float32(bs_list[0])
if bs_val == 0.5:
# logit(0.5) == 0, so omit base_values entirely
attr_pairs.pop("base_values", None)
else:
logit_bs = float(-np.log(1.0 / bs_val - 1.0))
attr_pairs["base_values"] = [logit_bs]
Comment thread
JOSH1024 marked this conversation as resolved.

raw_name = scope.get_unique_variable_name("binary_logistic_raw")
container.add_node(
"TreeEnsembleRegressor",
operator.input_full_names,
[raw_name],
op_domain="ai.onnx.ml",
name=scope.get_unique_operator_name("TreeEnsembleRegressor"),
**attr_pairs,
)
container.add_node(
"Sigmoid",
[raw_name],
operator.output_full_names,
name=scope.get_unique_operator_name("Sigmoid"),
)
return

# add nodes
objectives_with_loglink = {"count:poisson", "reg:gamma", "reg:tweedie"}
if objective in objectives_with_loglink:
names = [scope.get_unique_variable_name("tree")]
del attr_pairs["base_values"]
attr_pairs.pop("base_values", None)
else:
attr_pairs["base_values"] = bs_list
names = operator.output_full_names

container.add_node(
"TreeEnsembleRegressor",
operator.input_full_names,
Expand All @@ -427,7 +460,7 @@ def convert(scope, operator, container):
if objective in objectives_with_loglink:
cst = scope.get_unique_variable_name("raw_prediction")
container.add_initializer(
cst, TensorProto.FLOAT, [len(base_score)], base_score
cst, TensorProto.FLOAT, [len(bs_list)], bs_list
)
new_name = scope.get_unique_variable_name("exp")
container.add_node("Exp", names, [new_name])
Expand Down Expand Up @@ -609,4 +642,4 @@ def convert_xgboost(scope, operator, container):
register_converter("XGBClassifier", convert_xgboost)
register_converter("XGBRFClassifier", convert_xgboost)
register_converter("XGBRegressor", convert_xgboost)
register_converter("XGBRFRegressor", convert_xgboost)
register_converter("XGBRFRegressor", convert_xgboost)
27 changes: 14 additions & 13 deletions tests/xgboost/test_xgboost_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -868,15 +868,16 @@ def test_xgb_classifier_13_2(self):

initial_types = [("float_input", FloatTensorType([None, x_train.shape[1]]))]
onnx_model = convert_xgboost(model, initial_types=initial_types)
for att in onnx_model.graph.node[0].attribute:
if att.name == "nodes_treeids":
self.assertLess(max(att.ints), 1000)
if att.name == "class_ids":
self.assertEqual(set(att.ints), {0})
if att.name == "base_values":
self.assertEqual(len(att.floats), 1)
if att.name == "post_transform":
self.assertEqual(att.s, b"LOGISTIC")
tree_node = next(
node
for node in onnx_model.graph.node
if node.op_type == "TreeEnsembleClassifier"
)
tree_attrs = {att.name: att for att in tree_node.attribute}
self.assertLess(max(tree_attrs["nodes_treeids"].ints), 1000)
if "base_values" in tree_attrs:
self.assertEqual(len(tree_attrs["base_values"].floats), 1)
self.assertEqual(tree_attrs["post_transform"].s, b"LOGISTIC")

expected = model.predict(x_test), model.predict_proba(x_test)
sess = InferenceSession(onnx_model.SerializeToString())
Expand Down Expand Up @@ -931,10 +932,10 @@ def test_xgb_regressor_categorical_hist(self):
# Build the ONNX input:
# - first column: pandas category codes (0-based int codes) cast to float32
# - second column: numeric feature
# Note: X[["f0"]].values gives actual category values (e.g. 65, 66, 67),
# Note: X[["f0"]].values gives actual category values (e.g. 65, 66, 67),
Comment thread
JOSH1024 marked this conversation as resolved.
Outdated
# but XGBoost stores category codes (0, 1, 2...) in its tree JSON dump,
# so ONNX BRANCH_EQ nodes compare against codes, not raw values.
cat_codes = X["f0"].cat.codes.values.reshape(-1, 1).astype(np.float32)
cat_codes = X["f0"].cat.codes.to_numpy(dtype=np.float32).reshape(-1, 1)
num_col = X[["f1"]].values.astype(np.float32)
X_onnx = np.concatenate([cat_codes, num_col], axis=1)

Expand Down Expand Up @@ -995,8 +996,8 @@ def test_xgb_regressor_categorical_hist_native(self):
target_opset=TARGET_OPSET,
)

# Use pandas category codes (0, 1, 2...) not raw values (65, 66, 67...)
cat_codes = X["f0"].cat.codes.values.reshape(-1, 1).astype(np.float32)
# Use pandas category codes (0, 1, 2...) not raw values (65, 66, 67...)
Comment thread
JOSH1024 marked this conversation as resolved.
Outdated
cat_codes = X["f0"].cat.codes.to_numpy(dtype=np.float32).reshape(-1, 1)
num_col = X[["f1"]].values.astype(np.float32)
X_onnx = np.concatenate([cat_codes, num_col], axis=1)

Expand Down
67 changes: 65 additions & 2 deletions tests/xgboost/test_xgboost_issues.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# SPDX-License-Identifier: Apache-2.0

import unittest

try:
Expand Down Expand Up @@ -58,6 +57,70 @@ def xgbregressor_shape_calculator(operator):
got = sess.run(None, {"float_input": X.astype(np.float32)})
self.assertEqual(got[0].shape, (100, 2))

@unittest.skipIf(XGBRegressor is None, "xgboost is not available")
def test_issue_726_binary_logistic_subsample(self):
import numpy as np
import onnxruntime as rt
from skl2onnx import convert_sklearn, update_registered_converter
from skl2onnx.common.data_types import FloatTensorType
from skl2onnx.common.shape_calculator import (
calculate_linear_regressor_output_shapes,
)
from onnxmltools.convert.xgboost.operator_converters.XGBoost import (
convert_xgboost,
)

update_registered_converter(
XGBRegressor,
"XGBoostXGBRegressor",
calculate_linear_regressor_output_shapes,
convert_xgboost,
overwrite_existing=True,
)

X = np.array(
[[1.0], [2.0], [3.0], [4.0], [2.0], [3.0], [1.0], [2.0]],
dtype=np.float32,
)
y = np.array([1, 0, 1, 0, 1, 1, 0, 1], dtype=np.float32)

model = XGBRegressor(
max_depth=1,
n_estimators=3,
subsample=0.95,
objective="binary:logistic",
random_state=0,
)
Comment thread
andife marked this conversation as resolved.
model.fit(X, y)

initial_types = [("f1", FloatTensorType([None, 1]))]

onnx_model = convert_sklearn(
model,
"XGBoostXGBRegressor",
initial_types,
target_opset={"": 13, "ai.onnx.ml": 3},
)

sess = rt.InferenceSession(
onnx_model.SerializeToString(),
providers=["CPUExecutionProvider"],
)

got = sess.run(None, {"f1": X})[0]
expected = model.predict(X).reshape(-1, 1).astype(np.float32)

np.testing.assert_allclose(
got,
expected,
rtol=1e-5,
atol=1e-8,
err_msg=(
f"\nExpected: {expected.flatten()}"
f"\nONNX: {got.flatten()}"
),
)


if __name__ == "__main__":
unittest.main()
unittest.main()
Loading