Skip to content

feat: Implement new xgboost - #156

Open
gandola wants to merge 3 commits into
masterfrom
ft-pg-RLAB-617
Open

gandola wants to merge 3 commits into
masterfrom
ft-pg-RLAB-617

Conversation

@gandola

@gandola gandola commented Sep 18, 2026 •

Copy link
Copy Markdown

Adds a new OpenML provider, openml-xgboost, built directly on the upstream XGBoost JVM binding (ml.dmlc:xgboost4j_2.12:3.4.0) to be a replacement of the H2O-embedded XGBoost.

Phase 1 covers:

  • Model import — loadModel / loadSchema / validateForLoad.
  • Real-time single-instance scoring — classify / getClassDistribution via Booster.inplace_predict.
  • In-process (non-Spark) training — fit via XGBoost.train.

Key classes

  • XgboostModelProvider — TrainingMachineLearningProvider, algorithm XGBOOST_BINARY_CLASSIFIER.
  • XgboostModelCreator — loadModel / fit / validateForLoad / validateForFit.
  • XgboostClassificationModel — scoring; native handle guarded by a synchronized block (the Booster handle is not thread-safe).
  • XgboostSchemaUtils — shared feature-row builder used by both training and scoring (guarantees identical encoding).

Native runtime

  • xgboost4j bundles native libs for linux/{x86_64,aarch64}, macos/{x86_64,aarch64}, windows/x86_64 — ARM/Graviton works out of the box.
  • Requires OpenMP (libgomp/libomp) at runtime.
  • No musl/Alpine native binary is published — native-dependent tests are Assume-guarded and skip gracefully there.

Dependency handling (Kryo)

xgboost4j_2.12 transitively pulls Kryo 5, whose com.esotericsoftware.kryo.Kryo collides on the classpath with the Kryo 4 (kryo-shaded) used by Spark/consumers (Kryo 5 removed the nested Kryo.DefaultInstantiatorStrategy). Since Booster implements com.esotericsoftware.kryo.KryoSerializable, kryo must still be
present at class-load. Resolution: exclude kryo from the xgboost4j dependency and re-declare it in provided scope — available for this module's own compile/tests, not propagated transitively to consumers (which supply their own Kryo).

Testing

  • 20 tests; native-dependent ones Assume-skip where the native lib can't load (musl).
  • Coverage: 154/159 lines, 38/40 branches. Remaining gaps are the jackson-backed loadSchema one-line delegation and two native-exception catch blocks.
  • Verified train + save/reload produces identical scores; validation rejects string/free-text feature fields.

@codecov

codecov Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.71069% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.74%. Comparing base (18a41e9) to head (96dad3b).

Files with missing lines Patch % Lines
...l/provider/xgboost/XgboostClassificationModel.java 83.33% 5 Missing and 1 partial ⚠️
...i/openml/provider/xgboost/XgboostModelCreator.java 95.40% 3 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master     #156      +/-   ##
============================================
+ Coverage     81.76%   82.74%   +0.98%     
- Complexity      525      579      +54     
============================================
  Files            52       58       +6     
  Lines          1771     1930     +159     
  Branches        179      193      +14     
============================================
+ Hits           1448     1597     +149     
- Misses          234      242       +8     
- Partials         89       91       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

/**
* Specifies the XGBoost algorithms that can be imported and trained through this provider.
*
* @since 1.0.0

@fdz-joao-santos fdz-joao-santos Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not true, also, not needed.

@gandola
gandola requested a review from a team October 13, 2026 18:00

@jpleitao jpleitao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall the code looks good, just added some comments regarding (i) the set of parameters being exposed, (ii) default values for some parameters, and (iii) validations

XGBOOST_BINARY_CLASSIFIER(createDescriptor(
"DMLC - XGBoost",
XgboostDescriptorUtil.PARAMS,
MachineLearningAlgorithmType.SUPERVISED_BINARY_CLASSIFICATION,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See also the comment in validateForFit(), but in short XGBoost is being declared here as binary classification, but no such validation that the target is actually binary is being performed

/**
* The set of parameters accepted when training an XGBoost model.
*/
static final Set<ModelParameter> PARAMS = ImmutableSet.of(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the XGBoost support in an internal data science Python package, and the XGBoost documentation, it seems to me that some supported parameters are note defined here:

  1. max_delta_step (default 0.0) — Maximum absolute value of a leaf node prediction
  2. colsample_bylevel (default 1.0) — Column sample rate
  3. tree_method (default auto/hist) - Tree construction algorithm used in XGBoost. Possible values: auto, exact, approx, hist, where auto is the same as hist
  4. grow_policy (default depthwise) - Controls a way new nodes are added to the tree. Possible values: depthwise, lossguide
  5. max_leaves (default 0) — Maximum number of nodes to be added. Not used by exact tree method.
  6. max_bin (default 256) — Maximum number of bins. Only used if tree_method is set to hist or approx

While not defined in the internal DS Python package, could also make sense to define:

  • scale_pos_weight (default 1.0) — Control the balance of positive and negative weights, useful for unbalanced classes.

new ModelParameter(
OBJECTIVE_PARAMETER_NAME,
"Objective",
"The learning task and corresponding objective:\n"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If XGBoost is declared to be MachineLearningAlgorithmType.SUPERVISED_BINARY_CLASSIFICATION, then it doesn't make sense to allow for objective = multi:softprob (it also requires a num_class parameter indicating the number of classes, but since this XGboost is for binary classification, no such parameter is exposed).

My suggestion is to remove multi:softprob from the set of possible values of this parameter and add a check in XgboostModelCreator.validateForFit() thorwing an error in case objective=multi:softprob is specified

"Objective",
"The learning task and corresponding objective:\n"
+ "'binary:logistic' outputs the probability of the positive class,\n"
+ "'binary:logitraw' outputs the raw (pre-sigmoid) score,\n"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

objective = binary:logitraw outputs a raw pre-sigmoid score (unbounded, can be negative), but XgboostClassificationModel.toClassDistribution() treats any single-value output as a probability and returns [1-p, p]. As such, when objective = binary:logitraw is specified, [1-p, p] can fall outside the [0, 1] interval.

My suggestion is to remove binary:logitraw from the set of possible values for this parameter, and add a check in XgboostModelCreator.validateForFit() throwing an error in case objective=binary:logitraw is specified. It is also possible to keep allowing binary:logitraw and apply a sigmoid in XgboostModelCreator.validateForFit(), but that would produce the same as using objective=binary:logistic

"Number of boosting rounds",
"Number of boosting iterations (trees) to build.",
MANDATORY,
intRange(1, Integer.MAX_VALUE, 100)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the internal DS Python package a default value of 50 is being used. Consider adopting the same default value here in order to align with the previous implementation

"Number of threads",
"Number of parallel threads used to run XGBoost. Defaults to 1 for deterministic behavior.",
NOT_MANDATORY,
intRange(1, Integer.MAX_VALUE, 1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the internal DS Python package, this parameter takes None as default value, which means all available threads can be used. In native XGBoost, this is achieved by leaving this unset or specifying nthread=0, so I think the minimum value of this parameter should be changed to 0. The description of the parameter could also be updated in order to specify that 0 means using all available threads.

It is also worth noting that, while keeping 1 as the default value is safe, the default in H2O was to use all threads, meaning that this code could introduce slower default training.


booster = XGBoost.train(trainMatrix, boosterParams, numRound, new HashMap<>(), null, null);

final Path exportDir = exportModel(booster);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be worth adding a comment explaining the rationale being saving the model to file and reading again: my understanding is that something similar is already done in this repository for LightGBM, and we are also ensuring that fit() shares the same code as model import, so a freshly-trained model scores identically to the same model saved and reloaded.

But I could be missing or misunderstanding something.

booster = XGBoost.train(trainMatrix, boosterParams, numRound, new HashMap<>(), null, null);

final Path exportDir = exportModel(booster);
return loadModel(exportDir, schema);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In exportModel() a temporary directory is created but after loadModel() it is never removed.

Consider clearing that temporary folder in the finally block. As a starting point, this is what is currently being done for LightGBM (single temporary file instead of temporary directory):

try {
    Files.delete(tmpModelFilePath);
} catch (final IOException e) {
    logger.error("Could not delete temporary model file: {}", e.getMessage());
}

@Override
public List<ParamValidationError> validateForFit(final Path pathToPersist,
final DatasetSchema schema,
final Map<String, String> params) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is the best place to this, but could be worth performing some validations that the target is binary, just like it is done for LightGBM:

if (getNumTargetClasses(schema).orElse(-1) != 2) {
     errorsBuilder.add(new ParamValidationError(ERROR_MSG_NON_BINARY_TARGET));
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants