From aa084d6412ea87b3b41ba343b0458106e3928803 Mon Sep 17 00:00:00 2001 From: Jonas Rembser Date: Fri, 31 Jul 2026 09:27:49 +0000 Subject: [PATCH 1/4] [tmva] Implement XGBoost-to-RBDT conversion in C++ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the XGBoost-to-ROOT path (SaveXGBoost) from Python to C++. The new TMVA::Experimental::SaveXGBoost() and RBDT::LoadXGBoost() take an XGBoost model in its native JSON serialization (as written by Booster.save_model()) and parse it with nlohmann-json, which is already a ROOT dependency. The tree structure, objective, base score and number of classes are all read from the file, so the conversion no longer needs a live Python xgboost object. The Python wrapper (_tree_inference.py) is removed. Python users now serialize the model to a file first and call the C++ function with the path, exactly like C++ users would: model.get_booster().save_model("model.json") ROOT.TMVA.Experimental.SaveXGBoost("model.json", "key", "out.root") End goal: with an XGBoost JSON model we can deploy BDTs directly in C++, so the XGBoost-to-ROOT path is no longer Python-exclusive. This is also a stepping stone towards dropping RBDT serialization via ROOT I/O altogether: once models can be loaded straight from XGBoost JSON, RBDT no longer needs to be a persistable class. That relieves the awkward situation where an experimental class is written to disk and must therefore keep supporting its on-disk schema forever. 🤖 Done with the help of AI. --- .../pythonizations/python/CMakeLists.txt | 1 - .../pythonizations/python/ROOT/_facade.py | 2 - .../_pythonization/_tmva/_tree_inference.py | 94 ----------- tmva/tmva/CMakeLists.txt | 2 + tmva/tmva/inc/TMVA/RBDT.hxx | 4 + tmva/tmva/src/RBDT.cxx | 153 ++++++++++++++++++ tmva/tmva/test/rbdt_xgboost.py | 51 ++++-- .../machine_learning/tmva101_Training.py | 7 +- 8 files changed, 201 insertions(+), 113 deletions(-) delete mode 100644 bindings/pyroot/pythonizations/python/ROOT/_pythonization/_tmva/_tree_inference.py diff --git a/bindings/pyroot/pythonizations/python/CMakeLists.txt b/bindings/pyroot/pythonizations/python/CMakeLists.txt index c7a4de7c4fab4..331a5cca5718f 100644 --- a/bindings/pyroot/pythonizations/python/CMakeLists.txt +++ b/bindings/pyroot/pythonizations/python/CMakeLists.txt @@ -58,7 +58,6 @@ if(tmva) ROOT/_pythonization/_tmva/__init__.py ROOT/_pythonization/_tmva/_rbdt.py ROOT/_pythonization/_tmva/_rtensor.py - ROOT/_pythonization/_tmva/_tree_inference.py ROOT/_pythonization/_tmva/_utils.py ROOT/_pythonization/_tmva/_gnn.py ROOT/_pythonization/_tmva/_sofie/_parser/_keras/__init__.py diff --git a/bindings/pyroot/pythonizations/python/ROOT/_facade.py b/bindings/pyroot/pythonizations/python/ROOT/_facade.py index b845b77dc852f..7aa486ab5efba 100644 --- a/bindings/pyroot/pythonizations/python/ROOT/_facade.py +++ b/bindings/pyroot/pythonizations/python/ROOT/_facade.py @@ -567,13 +567,11 @@ def TMVA(self): from ._pythonization._tmva._rtensor import _AsRTensor from ._pythonization._tmva._sofie._parser._keras.parser import PyKeras from ._pythonization._tmva._sofie._parser._pytorch.parser import PyTorch - from ._pythonization._tmva._tree_inference import SaveXGBoost setattr(ns.Experimental.SOFIE, "PyKeras", PyKeras) setattr(ns.Experimental.SOFIE, "PyTorch", PyTorch) ns.Experimental.AsRTensor = _AsRTensor - ns.Experimental.SaveXGBoost = SaveXGBoost except ImportError: # _tmva submodule not available (expected for tmva=OFF) pass diff --git a/bindings/pyroot/pythonizations/python/ROOT/_pythonization/_tmva/_tree_inference.py b/bindings/pyroot/pythonizations/python/ROOT/_pythonization/_tmva/_tree_inference.py deleted file mode 100644 index 7a8bb4b4fbd91..0000000000000 --- a/bindings/pyroot/pythonizations/python/ROOT/_pythonization/_tmva/_tree_inference.py +++ /dev/null @@ -1,94 +0,0 @@ -# Author: Stefan Wunsch CERN 09/2019 - -################################################################################ -# Copyright (C) 1995-2019, Rene Brun and Fons Rademakers. # -# All rights reserved. # -# # -# For the licensing terms see $ROOTSYS/LICENSE. # -# For the list of contributors see $ROOTSYS/README/CREDITS. # -################################################################################ - -import json - - -def get_basescore(model): - """Get base score from an XGBoost sklearn estimator. - - Copy-pasted from XGBoost unit test code. - - See also: - * https://github.com/dmlc/xgboost/blob/2463938/python-package/xgboost/testing/updater.py#L43 - * https://github.com/dmlc/xgboost/issues/9347 - * https://discuss.xgboost.ai/t/how-to-get-base-score-from-trained-booster/3192 - """ - jintercept = json.loads(model.get_booster().save_config())["learner"]["learner_model_param"]["base_score"] - out = json.loads(jintercept) - if isinstance(out, float): - return out - # For XGBoost 3.1.0 and after, the value is itself a list. - # However, we don't support multiple base scores yet. - if len(out) > 1: - raise ValueError( - f"Model contains multiple base scores ({out}). " - "This typically occurs with XGBoost ≥ 3.1.0, which supports multi-target base scores. " - "This function only supports a single base score. " - ) - return out[0] - - -def SaveXGBoost(xgb_model, key_name, output_path, num_inputs): - """ - Saves the XGBoost model to a ROOT file as a TMVA::Experimental::RBDT object. - - Args: - xgb_model: The trained XGBoost model. - key_name (str): The name to use for storing the RBDT in the output file. - output_path (str): The path to save the output file. - num_inputs (int): The number of input features used in the model. - - Raises: - Exception: If the XGBoost model has an unsupported objective. - """ - import ROOT - - # Extract objective - objective_map = { - "multi:softprob": "softmax", # Naming the objective softmax is more common today - "binary:logistic": "logistic", - "reg:linear": "identity", - "reg:squarederror": "identity", - } - model_objective = xgb_model.objective - if model_objective not in objective_map: - raise Exception( - 'XGBoost model has unsupported objective "{}". Supported objectives are {}.'.format( - model_objective, objective_map.keys() - ) - ) - objective = ROOT.std.string(objective_map[model_objective]) - - # Determine number of outputs - num_outputs = xgb_model.n_classes_ if "multi:" in model_objective else 1 - - # Dump XGB model as json file - xgb_model.get_booster().dump_model(output_path, dump_format="json") - - # Dump XGB model as txt file - xgb_model.get_booster().dump_model(output_path) - - if xgb_model.get_booster().feature_names is None: - features = ROOT.std.vector["std::string"]([f"f{i}" for i in range(num_inputs)]) - else: - features = ROOT.std.vector["std::string"](xgb_model.get_booster().feature_names) - bs = get_basescore(xgb_model) - logistic = objective == "logistic" - bdt = ROOT.TMVA.Experimental.RBDT.LoadText( - output_path, - features, - num_outputs, - logistic, - ROOT.std.log(bs / (1.0 - bs)) if logistic else bs, - ) - - with ROOT.TFile.Open(output_path, "RECREATE") as tFile: - tFile.WriteObject(bdt, key_name) diff --git a/tmva/tmva/CMakeLists.txt b/tmva/tmva/CMakeLists.txt index 1adae4b59430f..d36b33341a15e 100644 --- a/tmva/tmva/CMakeLists.txt +++ b/tmva/tmva/CMakeLists.txt @@ -455,6 +455,8 @@ ROOT_STANDARD_LIBRARY_PACKAGE(TMVAUtils ${EXTRA_DICT_OPTS} ) +# RBDT::LoadXGBoost parses the XGBoost native JSON serialization. +target_link_libraries(TMVAUtils PRIVATE nlohmann_json::nlohmann_json) endif() ROOT_ADD_TEST_SUBDIRECTORY(test) diff --git a/tmva/tmva/inc/TMVA/RBDT.hxx b/tmva/tmva/inc/TMVA/RBDT.hxx index 3bd428f732a4b..6eb5099887aeb 100644 --- a/tmva/tmva/inc/TMVA/RBDT.hxx +++ b/tmva/tmva/inc/TMVA/RBDT.hxx @@ -65,6 +65,8 @@ public: static RBDT LoadText(std::string const &txtpath, std::vector &features, int nClasses, bool logistic, Value_t baseScore); + static RBDT LoadXGBoost(std::string const &jsonPath); + private: /// Map from XGBoost to RBDT indices. using IndexMap = std::unordered_map; @@ -92,6 +94,8 @@ private: ClassDefNV(RBDT, 1); }; +void SaveXGBoost(std::string const &jsonPath, std::string const &keyName, std::string const &outputPath); + } // namespace Experimental } // namespace TMVA diff --git a/tmva/tmva/src/RBDT.cxx b/tmva/tmva/src/RBDT.cxx index ca2858e15e201..b30efad423d22 100644 --- a/tmva/tmva/src/RBDT.cxx +++ b/tmva/tmva/src/RBDT.cxx @@ -23,7 +23,10 @@ #include #include +#include + #include +#include #include #include #include @@ -354,6 +357,156 @@ TMVA::Experimental::RBDT TMVA::Experimental::RBDT::LoadText(std::istream &file, return ff; } +/// Construct an RBDT from an XGBoost model in its native JSON serialization. +/// +/// In contrast to LoadText(), which parses the human-readable text dump, this +/// reads the structured model that XGBoost writes with Booster.save_model(). +/// That format stores each tree as a set of parallel arrays and references +/// features by index, so no feature-name resolution is needed. Everything else +/// (objective, base score, number of classes) is taken from the file, which +/// makes this a self-contained, Python-free entry point. +TMVA::Experimental::RBDT TMVA::Experimental::RBDT::LoadXGBoost(std::string const &jsonPath) +{ + const std::string info = "constructing RBDT from '" + jsonPath + "': "; + + if (gSystem->AccessPathName(jsonPath.c_str())) { + throw std::runtime_error(info + "file does not exist"); + } + + nlohmann::json j; + { + std::ifstream jsonFile(jsonPath.c_str()); + jsonFile >> j; + } + + auto const &learner = j.at("learner"); + auto const &modelParam = learner.at("learner_model_param"); + + // Map the XGBoost objective to the RBDT one, matching the Python SaveXGBoost. + std::string const xgbObjective = learner.at("objective").at("name").get(); + static const std::unordered_map objectiveMap{ + {"multi:softprob", "softmax"}, // Naming the objective softmax is more common today + {"binary:logistic", "logistic"}, + {"reg:linear", "identity"}, + {"reg:squarederror", "identity"}, + }; + auto foundObjective = objectiveMap.find(xgbObjective); + if (foundObjective == objectiveMap.end()) { + std::string supported; + for (auto const &item : objectiveMap) { + supported += (supported.empty() ? "" : ", ") + item.first; + } + throw std::runtime_error(info + "XGBoost model has unsupported objective \"" + xgbObjective + + "\". Supported objectives are " + supported + "."); + } + bool const logistic = foundObjective->second == "logistic"; + + // The base score is stored as a string, e.g. "5.14E-1". Since XGBoost 3.1.0 it + // is always serialized as a JSON array embedded in that string (e.g. + // "[5.14E-1]"), even for single-output models. Only a genuine multi-element + // array (multi-target base score) is unsupported. + std::string const baseScoreStr = modelParam.at("base_score").get(); + double baseScoreProb; + if (baseScoreStr.find('[') != std::string::npos) { + nlohmann::json const baseScoreArr = nlohmann::json::parse(baseScoreStr); + if (baseScoreArr.size() > 1) { + throw std::runtime_error(info + "model contains multiple base scores, which is not supported. This " + "typically occurs with XGBoost >= 3.1.0, which supports multi-target base " + "scores."); + } + baseScoreProb = baseScoreArr.at(0).get(); + } else { + baseScoreProb = std::stod(baseScoreStr); + } + // For a logistic objective the base score is a probability, but RBDT works on + // the raw margin, so we apply the logit transform (as the Python code does). + Value_t const baseScore = logistic ? std::log(baseScoreProb / (1.0 - baseScoreProb)) : baseScoreProb; + + // Only multiclass models produce more than one output. + int nClasses = 1; + if (xgbObjective.rfind("multi:", 0) == 0) { + nClasses = std::stoi(modelParam.at("num_class").get()); + } + + RBDT ff; + ff.fLogistic = logistic; + ff.fBaseScore = baseScore; + ff.fBaseResponses.resize(nClasses <= 2 ? 1 : nClasses); + + auto const &trees = learner.at("gradient_booster").at("model").at("trees"); + + int treesSkipped = 0; + int nPreviousNodes = 0; + int nPreviousLeaves = 0; + IndexMap nodeIndices; + IndexMap leafIndices; + + // Fill the flat RBDT arrays tree by tree, keying the index maps by the node's + // position in the XGBoost arrays. terminateTree() then remaps the child + // references to the RBDT indexing (negated for leaves), exactly as for the + // text dump. Node 0 is always the tree root, so iterating in array order + // makes it the first internal node of the tree, which is what fRootIndices + // expects. + for (auto const &tree : trees) { + auto const &leftChildren = tree.at("left_children"); + auto const &rightChildren = tree.at("right_children"); + auto const &splitIndices = tree.at("split_indices"); + auto const &splitConditions = tree.at("split_conditions"); + + std::size_t const nNodes = leftChildren.size(); + for (std::size_t i = 0; i < nNodes; ++i) { + int const left = leftChildren[i].get(); + if (left == -1) { + // Leaf node: the split condition holds the leaf response. + ff.fResponses.push_back(splitConditions[i].get()); + std::size_t const nLeafIndices = leafIndices.size(); + leafIndices[i] = nLeafIndices + nPreviousLeaves; + } else { + // Internal node: x < cut goes left (yes), otherwise right (no). + ff.fCutValues.push_back(splitConditions[i].get()); + ff.fCutIndices.push_back(splitIndices[i].get()); + ff.fLeftIndices.push_back(left); + ff.fRightIndices.push_back(rightChildren[i].get()); + std::size_t const nNodeIndices = nodeIndices.size(); + nodeIndices[i] = nNodeIndices + nPreviousNodes; + } + } + + terminateTree(ff, nPreviousNodes, nPreviousLeaves, nodeIndices, leafIndices, treesSkipped); + } + + if (nClasses > 2 && (ff.fRootIndices.size() + treesSkipped) % nClasses != 0) { + std::stringstream ss; + ss << info << "Forest has " << ff.fRootIndices.size() << " trees, which is not compatible with " << nClasses + << " classes!"; + throw std::runtime_error(ss.str()); + } + + return ff; +} + +/// Save an XGBoost model to a ROOT file as a TMVA::Experimental::RBDT object. +/// +/// \param jsonPath Path to the XGBoost model in its native JSON serialization +/// (as written by xgboost's Booster.save_model()). +/// \param keyName Name under which the RBDT is stored in the output file. +/// \param outputPath Path of the ROOT file to create (opened in RECREATE mode). +/// +/// This is the language-agnostic entry point for the XGBoost-to-ROOT path: it +/// only needs the model file on disk, so it can be used from C++ as well as +/// from Python. +void TMVA::Experimental::SaveXGBoost(std::string const &jsonPath, std::string const &keyName, + std::string const &outputPath) +{ + RBDT bdt = RBDT::LoadXGBoost(jsonPath); + + std::unique_ptr file{TFile::Open(outputPath.c_str(), "RECREATE")}; + if (!file || file->IsZombie()) { + throw std::runtime_error("Failed to open output file " + outputPath); + } + file->WriteObject(&bdt, keyName.c_str()); +} + TMVA::Experimental::RBDT::RBDT(const std::string &key, const std::string &filename) { std::unique_ptr file{TFile::Open(filename.c_str(), "READ")}; diff --git a/tmva/tmva/test/rbdt_xgboost.py b/tmva/tmva/test/rbdt_xgboost.py index 71e9a5b63a09f..27e9e1e8e7b8d 100644 --- a/tmva/tmva/test/rbdt_xgboost.py +++ b/tmva/tmva/test/rbdt_xgboost.py @@ -1,3 +1,5 @@ +import os +import tempfile import unittest import numpy as np @@ -8,6 +10,18 @@ np.random.seed(1234) +def save_xgboost(xgb, key_name, output_path): + """Serialize the model to XGBoost's native JSON format and convert it to an + RBDT in a ROOT file via the C++ TMVA::Experimental::SaveXGBoost.""" + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp_json: + json_path = tmp_json.name + try: + xgb.get_booster().save_model(json_path) + ROOT.TMVA.Experimental.SaveXGBoost(json_path, key_name, output_path) + finally: + os.remove(json_path) + + def create_dataset(num_events, num_features, num_outputs, dtype=np.float32): x = np.random.normal(0.0, 1.0, (num_events, num_features)).astype(dtype=dtype) if num_outputs == 1: @@ -19,22 +33,22 @@ def create_dataset(num_events, num_features, num_outputs, dtype=np.float32): return x, y -def _test_XGBBinary(label): +def _test_XGBBinary(output_path): """ Compare response of XGB classifier and TMVA tree inference system. """ x, y = create_dataset(1000, 10, 2) xgb = xgboost.XGBClassifier(n_estimators=100, max_depth=3) xgb.fit(x, y) - ROOT.TMVA.Experimental.SaveXGBoost(xgb, "myModel", "testXGBBinary{}.root".format(label), num_inputs=10) - bdt = ROOT.TMVA.Experimental.RBDT("myModel", "testXGBBinary{}.root".format(label)) + save_xgboost(xgb, "myModel", output_path) + bdt = ROOT.TMVA.Experimental.RBDT("myModel", output_path) y_xgb = xgb.predict_proba(x)[:, 1].squeeze() y_bdt = bdt.Compute(x).squeeze() np.testing.assert_array_almost_equal(y_xgb, y_bdt) -def _test_XGBRegression(label): +def _test_XGBRegression(output_path): """ Compare response of XGB regressor and TMVA tree inference system. """ @@ -44,29 +58,29 @@ def _test_XGBRegression(label): # Other than in the XGBBinary test, we're passing the training features via # a pandas DataFrame this time. In that case, XGBoost will define custom # feature names according to the column names in the dataframe, and we can - # test the case where the feature names in the .txt dump are not the - # default "f0", "f1", "f2", etc. + # test the case where the feature names are not the default "f0", "f1", + # "f2", etc. df_x = pandas.DataFrame({f"myfeature_{i}": x[:, i] for i in range(n_features)}) assert len(x) == len(df_x) xgb = xgboost.XGBRegressor(n_estimators=1, max_depth=3) xgb.fit(df_x, y) - ROOT.TMVA.Experimental.SaveXGBoost(xgb, "myModel", "testXGBRegression{}.root".format(label), num_inputs=10) - bdt = ROOT.TMVA.Experimental.RBDT("myModel", "testXGBRegression{}.root".format(label)) + save_xgboost(xgb, "myModel", output_path) + bdt = ROOT.TMVA.Experimental.RBDT("myModel", output_path) y_xgb = xgb.predict(x).squeeze() y_bdt = bdt.Compute(x).squeeze() np.testing.assert_array_almost_equal(y_xgb, y_bdt) -def _test_XGBMulticlass(label): +def _test_XGBMulticlass(output_path): """ Compare response of XGB multiclass and TMVA tree inference system. """ x, y = create_dataset(1000, 10, 3) xgb = xgboost.XGBClassifier(n_estimators=100, max_depth=3) xgb.fit(x, y) - ROOT.TMVA.Experimental.SaveXGBoost(xgb, "myModel", "testXGBMulticlass{}.root".format(label), num_inputs=10) - bdt = ROOT.TMVA.Experimental.RBDT("myModel", "testXGBMulticlass{}.root".format(label)) + save_xgboost(xgb, "myModel", output_path) + bdt = ROOT.TMVA.Experimental.RBDT("myModel", output_path) y_xgb = xgb.predict_proba(x) y_bdt = bdt.Compute(x) @@ -78,11 +92,20 @@ class RBDT(unittest.TestCase): Test RBDT interface """ + def setUp(self): + # Keep all model files in a temporary directory so the test leaves no + # spurious artifacts behind, regardless of the working directory. + self._tmpdir = tempfile.TemporaryDirectory() + self.output_path = os.path.join(self._tmpdir.name, "model.root") + + def tearDown(self): + self._tmpdir.cleanup() + def test_XGBBinary_default(self): """ Test model trained with binary XGBClassifier. """ - _test_XGBBinary("default") + _test_XGBBinary(self.output_path) def test_XGBMulticlass_default(self): """ @@ -90,13 +113,13 @@ def test_XGBMulticlass_default(self): """ if xgboost.__version__ >= "3.1.0": self.skipTest("We don't support multiclassification with xgboost>=3.1.0 yet") - _test_XGBMulticlass("default") + _test_XGBMulticlass(self.output_path) def test_XGBRegression_default(self): """ Test model trained with XGBRegressor. """ - _test_XGBRegression("default") + _test_XGBRegression(self.output_path) if __name__ == "__main__": diff --git a/tutorials/machine_learning/tmva101_Training.py b/tutorials/machine_learning/tmva101_Training.py index 671cad7836c93..753dae955c9ed 100644 --- a/tutorials/machine_learning/tmva101_Training.py +++ b/tutorials/machine_learning/tmva101_Training.py @@ -50,6 +50,9 @@ def load_data(signal_filename, background_filename): bdt = XGBClassifier(max_depth=3, n_estimators=500) bdt.fit(x, y, sample_weight=w) - # Save model in TMVA format + # Save model in TMVA format. The XGBoost-to-ROOT conversion is implemented + # in C++ and takes the model in XGBoost's native JSON serialization, so we + # dump the trained model to a file first. print("Training done on ", x.shape[0], "events. Saving model in tmva101.root") - ROOT.TMVA.Experimental.SaveXGBoost(bdt, "myBDT", "tmva101.root", num_inputs=x.shape[1]) + bdt.get_booster().save_model("tmva101.json") + ROOT.TMVA.Experimental.SaveXGBoost("tmva101.json", "myBDT", "tmva101.root") From 04177cdbf707cea247fa6d8c9f47f8c7ed956b0f Mon Sep 17 00:00:00 2001 From: Jonas Rembser Date: Fri, 31 Jul 2026 09:38:41 +0000 Subject: [PATCH 2/4] [tmva] Drop RBDT ROOT I/O support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the ability to persist RBDT to a ROOT file: the ClassDef, the dictionary/streamer (LinkDef entry), the RBDT(key, filename) reading constructor and the SaveXGBoost() free function are all gone. Models are now loaded straight from XGBoost's native JSON via RBDT::LoadXGBoost(), which is the only supported entry point, both in C++ and Python. The tutorials are updated accordingly: tmva101_Training now just writes the XGBoost JSON (tmva101.json), and tmva102_Testing / tmva103_Application load it with RBDT::LoadXGBoost instead of reading an RBDT from a ROOT file. The rbdt_xgboost test builds the RBDT directly from JSON and no longer produces an intermediate ROOT file. RBDT is no longer selected for a dictionary, but it stays fully usable from PyROOT and cling: the library is autoloaded through the C++ modules global index, and the Compute() pythonization still applies. This is the payoff of implementing the XGBoost-to-RBDT conversion in C++: because a model can always be reconstructed from its XGBoost JSON, RBDT does not need to be a persistable class. We therefore no longer commit to supporting an on-disk schema forever for what is an experimental class. 🤖 Done with the help of AI. --- README/ReleaseNotes/v642/index.md | 1 + tmva/tmva/inc/LinkDefUtils.h | 5 --- tmva/tmva/inc/TMVA/RBDT.hxx | 14 ++----- tmva/tmva/src/RBDT.cxx | 39 +------------------ tmva/tmva/test/rbdt_xgboost.py | 38 +++++++----------- .../machine_learning/tmva101_Training.py | 9 ++--- tutorials/machine_learning/tmva102_Testing.py | 6 +-- .../machine_learning/tmva103_Application.C | 6 +-- 8 files changed, 28 insertions(+), 90 deletions(-) diff --git a/README/ReleaseNotes/v642/index.md b/README/ReleaseNotes/v642/index.md index 88736926acd8a..4261292ca1193 100644 --- a/README/ReleaseNotes/v642/index.md +++ b/README/ReleaseNotes/v642/index.md @@ -50,6 +50,7 @@ The following people have contributed to this new version: * The inclusion by external projects of Makefile templates contained within ROOT is deprecated in 6.42, a warning will be raised if you use them. These files will be removed in ROOT 7. * The conversion from Python set to **RooArgSet** is deprecated and won't work anymore in ROOT 6.44. The problem is that Python sets are unordered while RooArgSets are ordered, and this mismatch can lead to subtle problems later on. Prefer conversion from Python lists or tuples, which are ordered too. * The ROOT IO capability for the `TMVA::Experimental::SOFIE::RModel` has been removed. Users should not be encouraged to serialize models in experimental classes. For the serialization of ONNX models one can already use ONNX directly, and even serialize the ONNX bytes to a ROOT file if required. +* The ROOT IO capability for the `TMVA::Experimental::RBDT` class has been removed, along with the `TMVA.Experimental.SaveXGBoost` Python function. Experimental classes should not be persistified since their on-disk layout is not guaranteed to be stable. An `RBDT` is now built directly from an XGBoost model in its native JSON serialization with the new `TMVA::Experimental::RBDT::LoadXGBoost(jsonPath)`, which works both from C++ and Python. To convert a trained model, save it first with XGBoost's `Booster.save_model("model.json")` and then load it with `LoadXGBoost`. * The **JsMVA** feature for interactive TMVA training in Jupyter notebooks is now removed. It was not functional for years and was therefore already excluded from ROOT 6.38. This also removes the `TMVA::IPythonInteractive` class and the related interactive-training interfaces from the TMVA method and fitter classes, such as `MethodBase::ExitFromTraining()` or `FitterBase::SetIPythonInteractive()`. * The **RooStats::DebuggingSampler** and **RooStats::DebuggingTestStat** classes are removed. They were mock implementations of the `TestStatSampler` and `TestStatistic` interfaces that returned uniform random numbers independent of the data, only meant for debugging the RooStats framework itself during its initial development. diff --git a/tmva/tmva/inc/LinkDefUtils.h b/tmva/tmva/inc/LinkDefUtils.h index 82709731c20f2..68de99aeacd8e 100644 --- a/tmva/tmva/inc/LinkDefUtils.h +++ b/tmva/tmva/inc/LinkDefUtils.h @@ -9,11 +9,6 @@ #pragma link C++ nestedclass; -#ifdef R__HAS_DATAFRAME -// BDT inference -#pragma link C++ class TMVA::Experimental::RBDT+; -#endif - // RTensor will have its own streamer function #pragma link C++ class TMVA::Experimental::RTensor>-; diff --git a/tmva/tmva/inc/TMVA/RBDT.hxx b/tmva/tmva/inc/TMVA/RBDT.hxx index 6eb5099887aeb..fc84f0397a67e 100644 --- a/tmva/tmva/inc/TMVA/RBDT.hxx +++ b/tmva/tmva/inc/TMVA/RBDT.hxx @@ -20,7 +20,6 @@ #ifndef TMVA_RBDT #define TMVA_RBDT -#include #include #include @@ -38,12 +37,6 @@ class RBDT final { public: typedef float Value_t; - /// IO constructor (both for ROOT IO and LoadText()). - RBDT() = default; - - /// Construct backends from model in ROOT file. - RBDT(const std::string &key, const std::string &filename); - /// Compute model prediction on a single event. /// /// The method is intended to be used with std::vectors-like containers, @@ -68,6 +61,9 @@ public: static RBDT LoadXGBoost(std::string const &jsonPath); private: + /// Private default constructor, used by the public LoadText() and LoadXGBoost() factories. + RBDT() = default; + /// Map from XGBoost to RBDT indices. using IndexMap = std::unordered_map; @@ -90,12 +86,8 @@ private: std::vector fBaseResponses; Value_t fBaseScore = 0.0; bool fLogistic = false; - - ClassDefNV(RBDT, 1); }; -void SaveXGBoost(std::string const &jsonPath, std::string const &keyName, std::string const &outputPath); - } // namespace Experimental } // namespace TMVA diff --git a/tmva/tmva/src/RBDT.cxx b/tmva/tmva/src/RBDT.cxx index b30efad423d22..cf8e88137e743 100644 --- a/tmva/tmva/src/RBDT.cxx +++ b/tmva/tmva/src/RBDT.cxx @@ -20,13 +20,11 @@ #include -#include #include #include #include -#include #include #include #include @@ -382,7 +380,7 @@ TMVA::Experimental::RBDT TMVA::Experimental::RBDT::LoadXGBoost(std::string const auto const &learner = j.at("learner"); auto const &modelParam = learner.at("learner_model_param"); - // Map the XGBoost objective to the RBDT one, matching the Python SaveXGBoost. + // Map the XGBoost objective to the RBDT one. std::string const xgbObjective = learner.at("objective").at("name").get(); static const std::unordered_map objectiveMap{ {"multi:softprob", "softmax"}, // Naming the objective softmax is more common today @@ -484,38 +482,3 @@ TMVA::Experimental::RBDT TMVA::Experimental::RBDT::LoadXGBoost(std::string const return ff; } - -/// Save an XGBoost model to a ROOT file as a TMVA::Experimental::RBDT object. -/// -/// \param jsonPath Path to the XGBoost model in its native JSON serialization -/// (as written by xgboost's Booster.save_model()). -/// \param keyName Name under which the RBDT is stored in the output file. -/// \param outputPath Path of the ROOT file to create (opened in RECREATE mode). -/// -/// This is the language-agnostic entry point for the XGBoost-to-ROOT path: it -/// only needs the model file on disk, so it can be used from C++ as well as -/// from Python. -void TMVA::Experimental::SaveXGBoost(std::string const &jsonPath, std::string const &keyName, - std::string const &outputPath) -{ - RBDT bdt = RBDT::LoadXGBoost(jsonPath); - - std::unique_ptr file{TFile::Open(outputPath.c_str(), "RECREATE")}; - if (!file || file->IsZombie()) { - throw std::runtime_error("Failed to open output file " + outputPath); - } - file->WriteObject(&bdt, keyName.c_str()); -} - -TMVA::Experimental::RBDT::RBDT(const std::string &key, const std::string &filename) -{ - std::unique_ptr file{TFile::Open(filename.c_str(), "READ")}; - if (!file || file->IsZombie()) { - throw std::runtime_error("Failed to open input file " + filename); - } - auto *fromFile = file->Get(key.c_str()); - if (!fromFile) { - throw std::runtime_error("No RBDT with name " + key); - } - *this = *fromFile; -} diff --git a/tmva/tmva/test/rbdt_xgboost.py b/tmva/tmva/test/rbdt_xgboost.py index 27e9e1e8e7b8d..acd9ca1ac71f0 100644 --- a/tmva/tmva/test/rbdt_xgboost.py +++ b/tmva/tmva/test/rbdt_xgboost.py @@ -10,14 +10,14 @@ np.random.seed(1234) -def save_xgboost(xgb, key_name, output_path): - """Serialize the model to XGBoost's native JSON format and convert it to an - RBDT in a ROOT file via the C++ TMVA::Experimental::SaveXGBoost.""" +def load_rbdt(xgb): + """Serialize the model to XGBoost's native JSON format and build an RBDT from + it via the C++ TMVA::Experimental::RBDT::LoadXGBoost.""" with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp_json: json_path = tmp_json.name try: xgb.get_booster().save_model(json_path) - ROOT.TMVA.Experimental.SaveXGBoost(json_path, key_name, output_path) + return ROOT.TMVA.Experimental.RBDT.LoadXGBoost(json_path) finally: os.remove(json_path) @@ -33,22 +33,21 @@ def create_dataset(num_events, num_features, num_outputs, dtype=np.float32): return x, y -def _test_XGBBinary(output_path): +def _test_XGBBinary(): """ Compare response of XGB classifier and TMVA tree inference system. """ x, y = create_dataset(1000, 10, 2) xgb = xgboost.XGBClassifier(n_estimators=100, max_depth=3) xgb.fit(x, y) - save_xgboost(xgb, "myModel", output_path) - bdt = ROOT.TMVA.Experimental.RBDT("myModel", output_path) + bdt = load_rbdt(xgb) y_xgb = xgb.predict_proba(x)[:, 1].squeeze() y_bdt = bdt.Compute(x).squeeze() np.testing.assert_array_almost_equal(y_xgb, y_bdt) -def _test_XGBRegression(output_path): +def _test_XGBRegression(): """ Compare response of XGB regressor and TMVA tree inference system. """ @@ -64,23 +63,21 @@ def _test_XGBRegression(output_path): assert len(x) == len(df_x) xgb = xgboost.XGBRegressor(n_estimators=1, max_depth=3) xgb.fit(df_x, y) - save_xgboost(xgb, "myModel", output_path) - bdt = ROOT.TMVA.Experimental.RBDT("myModel", output_path) + bdt = load_rbdt(xgb) y_xgb = xgb.predict(x).squeeze() y_bdt = bdt.Compute(x).squeeze() np.testing.assert_array_almost_equal(y_xgb, y_bdt) -def _test_XGBMulticlass(output_path): +def _test_XGBMulticlass(): """ Compare response of XGB multiclass and TMVA tree inference system. """ x, y = create_dataset(1000, 10, 3) xgb = xgboost.XGBClassifier(n_estimators=100, max_depth=3) xgb.fit(x, y) - save_xgboost(xgb, "myModel", output_path) - bdt = ROOT.TMVA.Experimental.RBDT("myModel", output_path) + bdt = load_rbdt(xgb) y_xgb = xgb.predict_proba(x) y_bdt = bdt.Compute(x) @@ -92,20 +89,11 @@ class RBDT(unittest.TestCase): Test RBDT interface """ - def setUp(self): - # Keep all model files in a temporary directory so the test leaves no - # spurious artifacts behind, regardless of the working directory. - self._tmpdir = tempfile.TemporaryDirectory() - self.output_path = os.path.join(self._tmpdir.name, "model.root") - - def tearDown(self): - self._tmpdir.cleanup() - def test_XGBBinary_default(self): """ Test model trained with binary XGBClassifier. """ - _test_XGBBinary(self.output_path) + _test_XGBBinary() def test_XGBMulticlass_default(self): """ @@ -113,13 +101,13 @@ def test_XGBMulticlass_default(self): """ if xgboost.__version__ >= "3.1.0": self.skipTest("We don't support multiclassification with xgboost>=3.1.0 yet") - _test_XGBMulticlass(self.output_path) + _test_XGBMulticlass() def test_XGBRegression_default(self): """ Test model trained with XGBRegressor. """ - _test_XGBRegression(self.output_path) + _test_XGBRegression() if __name__ == "__main__": diff --git a/tutorials/machine_learning/tmva101_Training.py b/tutorials/machine_learning/tmva101_Training.py index 753dae955c9ed..ff944253ca0eb 100644 --- a/tutorials/machine_learning/tmva101_Training.py +++ b/tutorials/machine_learning/tmva101_Training.py @@ -50,9 +50,8 @@ def load_data(signal_filename, background_filename): bdt = XGBClassifier(max_depth=3, n_estimators=500) bdt.fit(x, y, sample_weight=w) - # Save model in TMVA format. The XGBoost-to-ROOT conversion is implemented - # in C++ and takes the model in XGBoost's native JSON serialization, so we - # dump the trained model to a file first. - print("Training done on ", x.shape[0], "events. Saving model in tmva101.root") + # Save the trained model in XGBoost's native JSON format. It can be loaded + # back for inference with TMVA's fast tree inference engine via + # TMVA::Experimental::RBDT::LoadXGBoost, both from Python and from C++. + print("Training done on ", x.shape[0], "events. Saving model in tmva101.json") bdt.get_booster().save_model("tmva101.json") - ROOT.TMVA.Experimental.SaveXGBoost("tmva101.json", "myBDT", "tmva101.root") diff --git a/tutorials/machine_learning/tmva102_Testing.py b/tutorials/machine_learning/tmva102_Testing.py index 7d78e9b6147e5..59f95d37706fa 100644 --- a/tutorials/machine_learning/tmva102_Testing.py +++ b/tutorials/machine_learning/tmva102_Testing.py @@ -17,10 +17,10 @@ # Load data x, y_true, w = load_data("test_signal.root", "test_background.root") -# Load trained model -File = "tmva101.root" +# Load trained model from the XGBoost JSON written by tmva101_Training.py +File = "tmva101.json" -bdt = ROOT.TMVA.Experimental.RBDT("myBDT", File) +bdt = ROOT.TMVA.Experimental.RBDT.LoadXGBoost(File) # Make prediction y_pred = bdt.Compute(x) diff --git a/tutorials/machine_learning/tmva103_Application.C b/tutorials/machine_learning/tmva103_Application.C index 305cd927c778a..9e721bc8c3701 100644 --- a/tutorials/machine_learning/tmva103_Application.C +++ b/tutorials/machine_learning/tmva103_Application.C @@ -15,15 +15,15 @@ using namespace TMVA::Experimental; void tmva103_Application() { - const char* model_filename = "tmva101.root"; + const char* model_filename = "tmva101.json"; if (gSystem->AccessPathName(model_filename)) { Info("tmva103_Application.C", "%s does not exist", model_filename); return; } - // Load BDT model - RBDT bdt("myBDT", model_filename); + // Load BDT model from the XGBoost JSON written by tmva101_Training.py + RBDT bdt = RBDT::LoadXGBoost(model_filename); // Apply model on a single input auto y1 = bdt.Compute({1.0, 2.0, 3.0, 4.0}); From 9e622f8f61013626ba66f613f83a8298a5af7b83 Mon Sep 17 00:00:00 2001 From: Jonas Rembser Date: Fri, 31 Jul 2026 12:52:22 +0200 Subject: [PATCH 3/4] [tmva] Remove `RBDT::LoadText` for loading XGBoost text dump Outputs from XGBoosts `dump_model` should better not be used for IO. From The docs [1]: "The primary use case for it is for model interpretation and visualization, and is not supposed to be loaded back to XGBoost." Now that we natively support the real IO format of XBoost written by `booster.save_model('model.json')`, we can get rid of the text dump parser. [1] https://xgboost.readthedocs.io/en/stable/tutorials/saving_model.html --- tmva/tmva/inc/TMVA/RBDT.hxx | 7 +- tmva/tmva/src/RBDT.cxx | 137 +----------------------------------- 2 files changed, 2 insertions(+), 142 deletions(-) diff --git a/tmva/tmva/inc/TMVA/RBDT.hxx b/tmva/tmva/inc/TMVA/RBDT.hxx index fc84f0397a67e..40cda2e352724 100644 --- a/tmva/tmva/inc/TMVA/RBDT.hxx +++ b/tmva/tmva/inc/TMVA/RBDT.hxx @@ -55,13 +55,10 @@ public: RTensor Compute(RTensor const &x) const; - static RBDT LoadText(std::string const &txtpath, std::vector &features, int nClasses, bool logistic, - Value_t baseScore); - static RBDT LoadXGBoost(std::string const &jsonPath); private: - /// Private default constructor, used by the public LoadText() and LoadXGBoost() factories. + /// Private default constructor, used by the public LoadXGBoost() factory. RBDT() = default; /// Map from XGBoost to RBDT indices. @@ -73,8 +70,6 @@ private: static void correctIndices(std::span indices, IndexMap const &nodeIndices, IndexMap const &leafIndices); static void terminateTree(TMVA::Experimental::RBDT &ff, int &nPreviousNodes, int &nPreviousLeaves, IndexMap &nodeIndices, IndexMap &leafIndices, int &treesSkipped); - static RBDT - LoadText(std::istream &is, std::vector &features, int nClasses, bool logistic, Value_t baseScore); std::vector fRootIndices; std::vector fCutIndices; diff --git a/tmva/tmva/src/RBDT.cxx b/tmva/tmva/src/RBDT.cxx index cf8e88137e743..b06183fcfbf30 100644 --- a/tmva/tmva/src/RBDT.cxx +++ b/tmva/tmva/src/RBDT.cxx @@ -55,17 +55,6 @@ void softmaxTransformInplace(Value_t *out, int nOut) namespace util { -inline bool isInteger(const std::string &s) -{ - if (s.empty() || ((!isdigit(s[0])) && (s[0] != '-') && (s[0] != '+'))) - return false; - - char *p; - strtol(s.c_str(), &p, 10); - - return (*p == 0); -} - template struct NumericAfterSubstrOutput { explicit NumericAfterSubstrOutput() @@ -232,133 +221,9 @@ void TMVA::Experimental::RBDT::terminateTree(TMVA::Experimental::RBDT &ff, int & nPreviousLeaves = ff.fResponses.size(); } -TMVA::Experimental::RBDT TMVA::Experimental::RBDT::LoadText(std::string const &txtpath, - std::vector &features, int nClasses, - bool logistic, Value_t baseScore) -{ - const std::string info = "constructing RBDT from " + txtpath + ": "; - - if (gSystem->AccessPathName(txtpath.c_str())) { - throw std::runtime_error(info + "file does not exists"); - } - - std::ifstream file(txtpath.c_str()); - return LoadText(file, features, nClasses, logistic, baseScore); -} - -TMVA::Experimental::RBDT TMVA::Experimental::RBDT::LoadText(std::istream &file, std::vector &features, - int nClasses, bool logistic, Value_t baseScore) -{ - const std::string info = "constructing RBDT from istream: "; - - RBDT ff; - ff.fLogistic = logistic; - ff.fBaseScore = baseScore; - ff.fBaseResponses.resize(nClasses <= 2 ? 1 : nClasses); - - int treesSkipped = 0; - - int nVariables = 0; - std::unordered_map varIndices; - bool fixFeatures = false; - - if (!features.empty()) { - fixFeatures = true; - nVariables = features.size(); - for (int i = 0; i < nVariables; ++i) { - varIndices[features[i]] = i; - } - } - - std::string line; - - IndexMap nodeIndices; - IndexMap leafIndices; - - int nPreviousNodes = 0; - int nPreviousLeaves = 0; - - while (std::getline(file, line)) { - std::size_t foundBegin = line.find("["); - std::size_t foundEnd = line.find("]"); - if (foundBegin != std::string::npos) { - std::string subline = line.substr(foundBegin + 1, foundEnd - foundBegin - 1); - if (util::isInteger(subline) && !ff.fResponses.empty()) { - terminateTree(ff, nPreviousNodes, nPreviousLeaves, nodeIndices, leafIndices, treesSkipped); - } else if (!util::isInteger(subline)) { - std::stringstream ss(line); - int index; - ss >> index; - line = ss.str(); - - std::vector splitstring = ROOT::Split(subline, "<"); - std::string const &varName = splitstring[0]; - Value_t cutValue; - { - std::stringstream ss1(splitstring[1]); - ss1 >> cutValue; - } - if (!varIndices.count(varName)) { - if (fixFeatures) { - throw std::runtime_error(info + "feature " + varName + " not in list of features"); - } - varIndices[varName] = nVariables; - features.push_back(varName); - ++nVariables; - } - int yes; - int no; - util::NumericAfterSubstrOutput output = util::numericAfterSubstr(line, "yes="); - if (!output.failed) { - yes = output.value; - } else { - throw std::runtime_error(info + "problem while parsing the text dump"); - } - output = util::numericAfterSubstr(output.rest, "no="); - if (!output.failed) { - no = output.value; - } else { - throw std::runtime_error(info + "problem while parsing the text dump"); - } - - ff.fCutValues.push_back(cutValue); - ff.fCutIndices.push_back(varIndices[varName]); - ff.fLeftIndices.push_back(yes); - ff.fRightIndices.push_back(no); - std::size_t nNodeIndices = nodeIndices.size(); - nodeIndices[index] = nNodeIndices + nPreviousNodes; - } - - } else { - util::NumericAfterSubstrOutput output = util::numericAfterSubstr(line, "leaf="); - if (output.found) { - std::stringstream ss(line); - int index; - ss >> index; - line = ss.str(); - - ff.fResponses.push_back(output.value); - std::size_t nLeafIndices = leafIndices.size(); - leafIndices[index] = nLeafIndices + nPreviousLeaves; - } - } - } - terminateTree(ff, nPreviousNodes, nPreviousLeaves, nodeIndices, leafIndices, treesSkipped); - - if (nClasses > 2 && (ff.fRootIndices.size() + treesSkipped) % nClasses != 0) { - std::stringstream ss; - ss << "Error in RBDT construction : Forest has " << ff.fRootIndices.size() - << " trees, which is not compatible with " << nClasses << "classes!"; - throw std::runtime_error(ss.str()); - } - - return ff; -} - /// Construct an RBDT from an XGBoost model in its native JSON serialization. /// -/// In contrast to LoadText(), which parses the human-readable text dump, this -/// reads the structured model that XGBoost writes with Booster.save_model(). +/// This reads the structured model that XGBoost writes with Booster.save_model(). /// That format stores each tree as a set of parallel arrays and references /// features by index, so no feature-name resolution is needed. Everything else /// (objective, base score, number of classes) is taken from the file, which From eaa2ceccf29f5b4054ba8b336344700196ee050c Mon Sep 17 00:00:00 2001 From: Jonas Rembser Date: Mon, 3 Aug 2026 10:20:31 +0200 Subject: [PATCH 4/4] [CMake] Add missing dependency between TMVA tutorials --- tutorials/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tutorials/CMakeLists.txt b/tutorials/CMakeLists.txt index c0405be50f405..fca354e26032d 100644 --- a/tutorials/CMakeLists.txt +++ b/tutorials/CMakeLists.txt @@ -656,6 +656,7 @@ set (machine_learning-TMVACrossValidationRegression-depends tutorial-machine_lea set (machine_learning-TMVACrossValidationApplication-depends tutorial-machine_learning-TMVACrossValidation) set (machine_learning-tmva101_Training-depends tutorial-machine_learning-tmva100_DataPreparation-py) set (machine_learning-tmva102_Testing-depends tutorial-machine_learning-tmva101_Training-py) +set (machine_learning-tmva103_Application-depends tutorial-machine_learning-tmva101_Training-py) set (machine_learning-tmva003_RReader-depends tutorial-machine_learning-TMVAClassification) set (machine_learning-tmva004_RStandardScaler-depends tutorial-machine_learning-tmva003_RReader) set (machine_learning-pytorch-ApplicationClassificationPyTorch-depends tutorial-machine_learning-pytorch-ClassificationPyTorch-py)