diff --git a/openml-xgboost/pom.xml b/openml-xgboost/pom.xml
new file mode 100644
index 00000000..b8b91b32
--- /dev/null
+++ b/openml-xgboost/pom.xml
@@ -0,0 +1,135 @@
+
+
+
+
+
+ com.feedzai
+ openml-java
+ 0.0.0-SNAPSHOT
+
+ 4.0.0
+
+ openml-xgboost
+ OpenML XGBoost
+ Provider that imports, scores and trains XGBoost models using the native xgboost4j JVM package.
+
+
+
+ 3.4.0
+
+
+
+
+ com.feedzai
+ openml-api
+ provided
+
+
+ com.feedzai
+ openml-utils
+ provided
+
+
+
+
+ ml.dmlc
+ xgboost4j_2.13
+ ${xgboost.version}
+
+
+ org.scala-lang
+ scala-compiler
+
+
+
+
+
+ com.google.guava
+ guava
+
+
+ org.slf4j
+ slf4j-api
+
+
+ com.google.auto.service
+ auto-service
+
+
+
+
+ com.feedzai
+ openml-utils
+ test-jar
+ test
+
+
+ junit
+ junit
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+ org.apache.commons
+ commons-csv
+ test
+
+
+ commons-io
+ commons-io
+ test
+
+
+ ch.qos.logback
+ logback-classic
+ test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+
+ test-jar
+
+
+
+
+
+
+
diff --git a/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostAlgorithms.java b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostAlgorithms.java
new file mode 100644
index 00000000..13452769
--- /dev/null
+++ b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostAlgorithms.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2026 Feedzai
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.feedzai.openml.provider.xgboost;
+
+import com.feedzai.openml.provider.descriptor.MLAlgorithmDescriptor;
+import com.feedzai.openml.provider.descriptor.MachineLearningAlgorithmType;
+import com.feedzai.openml.util.algorithm.MLAlgorithmEnum;
+
+import static com.feedzai.openml.util.algorithm.MLAlgorithmEnum.createDescriptor;
+
+/**
+ * Specifies the XGBoost algorithms that can be imported and trained through this provider.
+ *
+ * @since 1.0.0
+ */
+public enum XgboostAlgorithms implements MLAlgorithmEnum {
+
+ /**
+ * XGBoost binary classifier.
+ */
+ XGBOOST_BINARY_CLASSIFIER(createDescriptor(
+ "XGBoost Binary Classifier",
+ XgboostDescriptorUtil.PARAMS,
+ MachineLearningAlgorithmType.SUPERVISED_BINARY_CLASSIFICATION,
+ "https://xgboost.readthedocs.io/"
+ ));
+
+ /**
+ * {@link MLAlgorithmDescriptor} for this algorithm.
+ */
+ private final MLAlgorithmDescriptor descriptor;
+
+ /**
+ * Constructor.
+ *
+ * @param descriptor {@link MLAlgorithmDescriptor} for this algorithm.
+ */
+ XgboostAlgorithms(final MLAlgorithmDescriptor descriptor) {
+ this.descriptor = descriptor;
+ }
+
+ @Override
+ public MLAlgorithmDescriptor getAlgorithmDescriptor() {
+ return this.descriptor;
+ }
+}
diff --git a/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostClassificationModel.java b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostClassificationModel.java
new file mode 100644
index 00000000..e68e2b78
--- /dev/null
+++ b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostClassificationModel.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2026 Feedzai
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.feedzai.openml.provider.xgboost;
+
+import com.feedzai.openml.data.Instance;
+import com.feedzai.openml.data.schema.DatasetSchema;
+import com.feedzai.openml.model.ClassificationMLModel;
+import com.feedzai.openml.provider.exception.ModelLoadingException;
+import ml.dmlc.xgboost4j.java.Booster;
+import ml.dmlc.xgboost4j.java.XGBoostError;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.file.Path;
+
+/**
+ * A classification model backed by a native XGBoost {@link Booster}, used for real-time single-instance
+ * scoring.
+ *
+ *
Scoring uses {@link Booster#inplace_predict(float[], int, int, float)} on a single-row feature
+ * vector, which avoids allocating a {@code DMatrix} per prediction. The native booster handle is not
+ * thread-safe, so predictions are serialized on a private lock (mirrors the H2O provider's approach).
+ *
+ * @since 1.0.0
+ */
+public class XgboostClassificationModel implements ClassificationMLModel {
+
+ /**
+ * Logger for this class.
+ */
+ private static final Logger logger = LoggerFactory.getLogger(XgboostClassificationModel.class);
+
+ /**
+ * Value used to signal a missing feature to XGBoost.
+ */
+ private static final float MISSING_VALUE = Float.NaN;
+
+ /**
+ * The native XGBoost booster.
+ */
+ private final Booster booster;
+
+ /**
+ * The schema the model uses.
+ */
+ private final DatasetSchema schema;
+
+ /**
+ * The number of predictive features expected by the model.
+ */
+ private final int numFeatures;
+
+ /**
+ * Lock serializing access to the non-thread-safe native booster during prediction.
+ */
+ private final Object predictLock = new Object();
+
+ /**
+ * Constructor.
+ *
+ * @param booster The trained/loaded native XGBoost booster.
+ * @param schema The {@link DatasetSchema} the model uses.
+ */
+ XgboostClassificationModel(final Booster booster, final DatasetSchema schema) {
+ this.booster = booster;
+ this.schema = schema;
+ this.numFeatures = XgboostSchemaUtils.numFeatures(schema);
+ }
+
+ @Override
+ public double[] getClassDistribution(final Instance instance) {
+ final float[] row = XgboostSchemaUtils.featureRow(instance, this.schema);
+
+ final float[][] predictions;
+ try {
+ // The native booster handle is not thread-safe; serialize predictions.
+ synchronized (this.predictLock) {
+ predictions = this.booster.inplace_predict(row, 1, this.numFeatures, MISSING_VALUE);
+ }
+ } catch (final XGBoostError e) {
+ throw new RuntimeException("XGBoost failed to score the instance.", e);
+ }
+
+ return toClassDistribution(predictions[0]);
+ }
+
+ @Override
+ public int classify(final Instance instance) {
+ final double[] distribution = getClassDistribution(instance);
+
+ int argMax = 0;
+ for (int i = 1; i < distribution.length; i++) {
+ if (distribution[i] > distribution[argMax]) {
+ argMax = i;
+ }
+ }
+ return argMax;
+ }
+
+ @Override
+ public boolean save(final Path dir, final String name) {
+ try {
+ this.booster.saveModel(dir.resolve(XgboostModelCreator.MODEL_BINARY_RESOURCE_FILE_NAME).toString());
+ return true;
+ } catch (final XGBoostError e) {
+ logger.error("Failed to save XGBoost model {} to {}.", name, dir, e);
+ return false;
+ }
+ }
+
+ @Override
+ public DatasetSchema getSchema() {
+ return this.schema;
+ }
+
+ @Override
+ public void close() {
+ this.booster.dispose();
+ }
+
+ /**
+ * Converts a raw XGBoost prediction row into a class distribution aligned with the schema's target
+ * classes.
+ *
+ *
For binary objectives XGBoost outputs a single value - the probability of the positive class -
+ * which is expanded to {@code [1 - p, p]}. For multi-class objectives ({@code multi:softprob}) the
+ * per-class probability vector is returned as-is.
+ *
+ * @param prediction The raw prediction row for a single instance.
+ * @return The class distribution.
+ */
+ private static double[] toClassDistribution(final float[] prediction) {
+ if (prediction.length == 1) {
+ final double positiveProbability = prediction[0];
+ return new double[]{1.0 - positiveProbability, positiveProbability};
+ }
+
+ final double[] distribution = new double[prediction.length];
+ for (int i = 0; i < prediction.length; i++) {
+ distribution[i] = prediction[i];
+ }
+ return distribution;
+ }
+}
diff --git a/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostDescriptorUtil.java b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostDescriptorUtil.java
new file mode 100644
index 00000000..fdbb98e1
--- /dev/null
+++ b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostDescriptorUtil.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright 2026 Feedzai
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.feedzai.openml.provider.xgboost;
+
+import com.feedzai.openml.provider.descriptor.ModelParameter;
+import com.feedzai.openml.provider.descriptor.fieldtype.ChoiceFieldType;
+import com.feedzai.openml.provider.descriptor.fieldtype.NumericFieldType;
+import com.google.common.collect.ImmutableSet;
+
+import java.util.Set;
+
+/**
+ * Organizes the Machine Learning hyper-parameters exposed for training XGBoost models.
+ *
+ *
The parameter names match the native XGBoost parameter names (see
+ * XGBoost Parameters) so they can
+ * be forwarded directly to the {@code xgboost4j} training API.
+ *
+ * @since 1.0.0
+ */
+final class XgboostDescriptorUtil {
+
+ /**
+ * Alias to ease readability of mandatory parameters.
+ */
+ private static final boolean MANDATORY = true;
+
+ /**
+ * Alias to ease readability of non-mandatory parameters.
+ */
+ private static final boolean NOT_MANDATORY = false;
+
+ /**
+ * The learning task and objective. Kept as a parameter (rather than hard-coded) so both binary and
+ * multi-class objectives can be selected.
+ */
+ static final String OBJECTIVE_PARAMETER_NAME = "objective";
+
+ /**
+ * The number of boosting rounds (trees). Passed as the {@code nrounds} argument of
+ * {@code XGBoost.train}, not as a booster parameter.
+ */
+ static final String NUM_ROUND_PARAMETER_NAME = "num_round";
+
+ /**
+ * Random seed parameter name.
+ */
+ static final String SEED_PARAMETER_NAME = "seed";
+
+ /**
+ * Number of parallel threads parameter name.
+ */
+ static final String NTHREAD_PARAMETER_NAME = "nthread";
+
+ /**
+ * The set of parameters accepted when training an XGBoost model.
+ */
+ static final Set PARAMS = ImmutableSet.of(
+ new ModelParameter(
+ OBJECTIVE_PARAMETER_NAME,
+ "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"
+ + "'multi:softprob' outputs a per-class probability vector.",
+ MANDATORY,
+ new ChoiceFieldType(
+ ImmutableSet.of("binary:logistic", "binary:logitraw", "multi:softprob"),
+ "binary:logistic"
+ )
+ ),
+ new ModelParameter(
+ NUM_ROUND_PARAMETER_NAME,
+ "Number of boosting rounds",
+ "Number of boosting iterations (trees) to build.",
+ MANDATORY,
+ intRange(1, Integer.MAX_VALUE, 100)
+ ),
+ new ModelParameter(
+ "eta",
+ "Learning rate (eta)",
+ "Step size shrinkage used in updates to prevent over-fitting. Also named 'learning_rate'.",
+ NOT_MANDATORY,
+ doubleRange(0.0, 1.0, 0.3)
+ ),
+ new ModelParameter(
+ "max_depth",
+ "Maximum tree depth",
+ "Maximum depth of a tree. Increasing this value makes the model more complex and more\n"
+ + "likely to over-fit. 0 means no limit.",
+ NOT_MANDATORY,
+ intRange(0, Integer.MAX_VALUE, 6)
+ ),
+ new ModelParameter(
+ "min_child_weight",
+ "Minimum child weight",
+ "Minimum sum of instance weight (hessian) needed in a child. Larger values are more\n"
+ + "conservative.",
+ NOT_MANDATORY,
+ doubleRange(0.0, Double.MAX_VALUE, 1.0)
+ ),
+ new ModelParameter(
+ "gamma",
+ "Minimum split loss (gamma)",
+ "Minimum loss reduction required to make a further partition on a leaf node.",
+ NOT_MANDATORY,
+ doubleRange(0.0, Double.MAX_VALUE, 0.0)
+ ),
+ new ModelParameter(
+ "subsample",
+ "Subsample ratio",
+ "Subsample ratio of the training instances. Setting it to 0.5 means XGBoost randomly\n"
+ + "samples half of the training data prior to growing trees.",
+ NOT_MANDATORY,
+ doubleRange(1E-6, 1.0, 1.0)
+ ),
+ new ModelParameter(
+ "colsample_bytree",
+ "Column subsample ratio by tree",
+ "Subsample ratio of columns when constructing each tree.",
+ NOT_MANDATORY,
+ doubleRange(1E-6, 1.0, 1.0)
+ ),
+ new ModelParameter(
+ "lambda",
+ "L2 regularization (lambda)",
+ "L2 regularization term on weights. Increasing this value makes the model more conservative.",
+ NOT_MANDATORY,
+ doubleRange(0.0, Double.MAX_VALUE, 1.0)
+ ),
+ new ModelParameter(
+ "alpha",
+ "L1 regularization (alpha)",
+ "L1 regularization term on weights. Increasing this value makes the model more conservative.",
+ NOT_MANDATORY,
+ doubleRange(0.0, Double.MAX_VALUE, 0.0)
+ ),
+ new ModelParameter(
+ SEED_PARAMETER_NAME,
+ "Seed",
+ "Random number seed used for reproducibility.",
+ NOT_MANDATORY,
+ intRange(0, Integer.MAX_VALUE, 0)
+ ),
+ new ModelParameter(
+ NTHREAD_PARAMETER_NAME,
+ "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)
+ )
+ );
+
+ /**
+ * This class is not meant to be instantiated.
+ */
+ private XgboostDescriptorUtil() {
+ }
+
+ /**
+ * Helper that builds a {@code DOUBLE} numeric range.
+ *
+ * @param minValue Minimum allowed value.
+ * @param maxValue Maximum allowed value.
+ * @param defaultValue Default value.
+ * @return The numeric field type.
+ */
+ private static NumericFieldType doubleRange(final double minValue,
+ final double maxValue,
+ final double defaultValue) {
+ return NumericFieldType.range(minValue, maxValue, NumericFieldType.ParameterConfigType.DOUBLE, defaultValue);
+ }
+
+ /**
+ * Helper that builds an {@code INT} numeric range.
+ *
+ * @param minValue Minimum allowed value.
+ * @param maxValue Maximum allowed value.
+ * @param defaultValue Default value.
+ * @return The numeric field type.
+ */
+ private static NumericFieldType intRange(final int minValue,
+ final int maxValue,
+ final int defaultValue) {
+ return NumericFieldType.range(minValue, maxValue, NumericFieldType.ParameterConfigType.INT, defaultValue);
+ }
+}
diff --git a/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostModelCreator.java b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostModelCreator.java
new file mode 100644
index 00000000..5b9cbee6
--- /dev/null
+++ b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostModelCreator.java
@@ -0,0 +1,284 @@
+/*
+ * Copyright 2026 Feedzai
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.feedzai.openml.provider.xgboost;
+
+import com.feedzai.openml.data.Dataset;
+import com.feedzai.openml.data.Instance;
+import com.feedzai.openml.data.schema.DatasetSchema;
+import com.feedzai.openml.provider.descriptor.fieldtype.ParamValidationError;
+import com.feedzai.openml.provider.exception.ModelLoadingException;
+import com.feedzai.openml.provider.exception.ModelTrainingException;
+import com.feedzai.openml.provider.model.MachineLearningModelTrainer;
+import com.feedzai.openml.util.load.LoadModelUtils;
+import com.feedzai.openml.util.load.LoadSchemaUtils;
+import com.feedzai.openml.util.validate.ValidationUtils;
+import com.google.common.collect.ImmutableList;
+import ml.dmlc.xgboost4j.java.Booster;
+import ml.dmlc.xgboost4j.java.DMatrix;
+import ml.dmlc.xgboost4j.java.XGBoost;
+import ml.dmlc.xgboost4j.java.XGBoostError;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+
+/**
+ * Loads and trains XGBoost models through the native {@code xgboost4j} JVM package.
+ *
+ * Training is fully in-process (no Spark), mirroring how the H2O provider trains inside an
+ * embedded in-JVM instance: the dataset is materialized into an in-memory {@link DMatrix},
+ * {@link XGBoost#train} builds the booster, the booster is exported and then reloaded. Because
+ * {@code xgboost4j} is a thin JNI wrapper it runs on Java 8-25 and on ARM (Graviton / Apple Silicon).
+ *
+ * @since 1.0.0
+ */
+public class XgboostModelCreator implements MachineLearningModelTrainer {
+
+ /**
+ * Logger for this class.
+ */
+ private static final Logger logger = LoggerFactory.getLogger(XgboostModelCreator.class);
+
+ /**
+ * Name of the model file written inside the model folder, using XGBoost's portable UBJSON format.
+ */
+ public static final String MODEL_BINARY_RESOURCE_FILE_NAME = "XGBoost_model.ubj";
+
+ /**
+ * Prefix for the temporary directory holding a freshly trained model before it is reloaded.
+ */
+ private static final String EXPORT_DIR_PREFIX = "fdz_xgboost_";
+
+ /**
+ * Value used to signal a missing feature to XGBoost.
+ */
+ private static final float MISSING_VALUE = Float.NaN;
+
+ /**
+ * Default number of boosting rounds used if the parameter is absent.
+ */
+ private static final int DEFAULT_NUM_ROUND = 100;
+
+ @Override
+ public XgboostClassificationModel loadModel(final Path modelPath, final DatasetSchema schema)
+ throws ModelLoadingException {
+
+ logger.info("Loading XGBoost model from [{}].", modelPath);
+ final String modelFilePath = resolveModelFile(modelPath).toAbsolutePath().toString();
+
+ try {
+ final Booster booster = XGBoost.loadModel(modelFilePath);
+ logger.info("XGBoost model loaded successfully.");
+ return new XgboostClassificationModel(booster, schema);
+ } catch (final XGBoostError e) {
+ throw new ModelLoadingException(
+ String.format("Failed to load the XGBoost model from [%s].", modelFilePath), e);
+ }
+ }
+
+ /**
+ * Resolves the actual model file to load, supporting both layouts used across the codebase:
+ *
+ * - the Pulse import / training-export layout {@code /model/};
+ * - a model file written directly at the root of a directory (as produced by
+ * {@link XgboostClassificationModel#save(Path, String)});
+ * - a direct path to the model file itself.
+ *
+ *
+ * @param modelPath The path provided to {@link #loadModel(Path, DatasetSchema)}.
+ * @return The path of the model file to load.
+ * @throws ModelLoadingException If the model file cannot be located within the model folder layout.
+ */
+ private static Path resolveModelFile(final Path modelPath) throws ModelLoadingException {
+ if (!Files.isDirectory(modelPath)) {
+ return modelPath;
+ }
+ if (Files.isDirectory(modelPath.resolve(LoadModelUtils.MODEL_FOLDER))) {
+ return LoadModelUtils.getModelFilePath(modelPath);
+ }
+ return modelPath.resolve(MODEL_BINARY_RESOURCE_FILE_NAME);
+ }
+
+ @Override
+ public DatasetSchema loadSchema(final Path modelPath) throws ModelLoadingException {
+ return LoadSchemaUtils.datasetSchemaFromJson(modelPath);
+ }
+
+ @Override
+ public List validateForLoad(final Path modelPath,
+ final DatasetSchema schema,
+ final Map params) {
+ final ImmutableList.Builder errorBuilder = ImmutableList.builder();
+
+ errorBuilder.addAll(ValidationUtils.baseLoadValidations(schema, params));
+ errorBuilder.addAll(ValidationUtils.validateModelInDir(modelPath));
+ ValidationUtils.validateCategoricalSchema(schema).ifPresent(errorBuilder::add);
+
+ return errorBuilder.build();
+ }
+
+ @Override
+ public XgboostClassificationModel fit(final Dataset dataset,
+ final Random random,
+ final Map params) throws ModelTrainingException {
+
+ final DatasetSchema schema = dataset.getSchema();
+
+ DMatrix trainMatrix = null;
+ Booster booster = null;
+ try {
+ trainMatrix = buildTrainMatrix(dataset);
+
+ final Map boosterParams = toBoosterParams(params, random);
+ final int numRound = numRoundOf(params);
+
+ booster = XGBoost.train(trainMatrix, boosterParams, numRound, new HashMap<>(), null, null);
+
+ final Path exportDir = exportModel(booster);
+ return loadModel(exportDir, schema);
+ } catch (final XGBoostError | IOException | ModelLoadingException e) {
+ throw new ModelTrainingException("Failed to train the XGBoost model.", e);
+ } finally {
+ if (booster != null) {
+ booster.dispose();
+ }
+ if (trainMatrix != null) {
+ trainMatrix.dispose();
+ }
+ }
+ }
+
+ @Override
+ public List validateForFit(final Path pathToPersist,
+ final DatasetSchema schema,
+ final Map params) {
+ final ImmutableList.Builder errorBuilder = ImmutableList.builder();
+
+ errorBuilder.addAll(ValidationUtils.validateModelPathToTrain(pathToPersist));
+ errorBuilder.addAll(ValidationUtils.checkParams(
+ XgboostAlgorithms.XGBOOST_BINARY_CLASSIFIER.getAlgorithmDescriptor(), params));
+ ValidationUtils.validateCategoricalSchema(schema).ifPresent(errorBuilder::add);
+
+ return errorBuilder.build();
+ }
+
+ /**
+ * Materializes the whole dataset into an in-memory dense {@link DMatrix} with its label column set.
+ *
+ * @param dataset The training dataset.
+ * @return The training {@link DMatrix}.
+ * @throws XGBoostError If the native matrix cannot be created.
+ * @throws ModelTrainingException If the dataset is empty.
+ */
+ private static DMatrix buildTrainMatrix(final Dataset dataset) throws XGBoostError, ModelTrainingException {
+ final DatasetSchema schema = dataset.getSchema();
+ final int numFeatures = XgboostSchemaUtils.numFeatures(schema);
+ // Supervised training requires a target; enforced by validateForFit via validateCategoricalSchema.
+ final int targetIndex = schema.getTargetIndex().orElseThrow(
+ () -> new IllegalStateException("Supervised training requires a schema with a target field."));
+
+ final List rows = new ArrayList<>();
+ final List labels = new ArrayList<>();
+
+ final Iterator iterator = dataset.getInstances();
+ while (iterator.hasNext()) {
+ final Instance instance = iterator.next();
+ labels.add((float) instance.getValue(targetIndex));
+ rows.add(XgboostSchemaUtils.featureRow(instance, schema));
+ }
+
+ final int numRows = rows.size();
+ if (numRows == 0) {
+ throw new ModelTrainingException("Received an empty training dataset for XGBoost.");
+ }
+
+ final float[] flatFeatures = new float[numRows * numFeatures];
+ final float[] labelArray = new float[numRows];
+ for (int row = 0; row < numRows; row++) {
+ System.arraycopy(rows.get(row), 0, flatFeatures, row * numFeatures, numFeatures);
+ labelArray[row] = labels.get(row);
+ }
+
+ final DMatrix trainMatrix = new DMatrix(flatFeatures, numRows, numFeatures, MISSING_VALUE);
+ trainMatrix.setLabel(labelArray);
+ return trainMatrix;
+ }
+
+ /**
+ * Translates the Pulse string parameters into the {@code Map} expected by
+ * {@code xgboost4j}. The {@value XgboostDescriptorUtil#NUM_ROUND_PARAMETER_NAME} entry is excluded
+ * because it is passed as the {@code nrounds} argument of {@link XGBoost#train}. A seed is derived
+ * from the supplied {@link Random} when not explicitly provided, for reproducibility.
+ *
+ * @param params The Pulse model parameters.
+ * @param random The source of randomness.
+ * @return The XGBoost booster parameters.
+ */
+ private static Map toBoosterParams(final Map params, final Random random) {
+ final Map boosterParams = new HashMap<>();
+
+ params.forEach((name, value) -> {
+ if (!XgboostDescriptorUtil.NUM_ROUND_PARAMETER_NAME.equals(name) && value != null && !value.isEmpty()) {
+ boosterParams.put(name, value);
+ }
+ });
+
+ boosterParams.putIfAbsent(XgboostDescriptorUtil.OBJECTIVE_PARAMETER_NAME, "binary:logistic");
+ boosterParams.putIfAbsent(XgboostDescriptorUtil.SEED_PARAMETER_NAME, random.nextInt(Integer.MAX_VALUE));
+
+ return boosterParams;
+ }
+
+ /**
+ * Reads the number of boosting rounds from the parameters, falling back to a default.
+ *
+ * @param params The Pulse model parameters.
+ * @return The number of boosting rounds.
+ */
+ private static int numRoundOf(final Map params) {
+ final String numRound = params.get(XgboostDescriptorUtil.NUM_ROUND_PARAMETER_NAME);
+ if (numRound == null || numRound.isEmpty()) {
+ return DEFAULT_NUM_ROUND;
+ }
+ return Integer.parseInt(numRound.trim());
+ }
+
+ /**
+ * Exports a trained booster following the Pulse model folder convention
+ * ({@code /model/}), so it can be reloaded through {@link #loadModel}.
+ *
+ * @param booster The trained booster.
+ * @return The export directory root.
+ * @throws IOException If the export directories/files cannot be created.
+ * @throws XGBoostError If the booster cannot be serialized.
+ */
+ private static Path exportModel(final Booster booster) throws IOException, XGBoostError {
+ final Path exportDir = Files.createTempDirectory(EXPORT_DIR_PREFIX);
+ final Path modelDir = Files.createDirectory(exportDir.resolve(LoadModelUtils.MODEL_FOLDER));
+ booster.saveModel(modelDir.resolve(MODEL_BINARY_RESOURCE_FILE_NAME).toString());
+ return exportDir;
+ }
+}
diff --git a/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostModelProvider.java b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostModelProvider.java
new file mode 100644
index 00000000..9b8e1abe
--- /dev/null
+++ b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostModelProvider.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2026 Feedzai
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.feedzai.openml.provider.xgboost;
+
+import com.feedzai.openml.provider.MachineLearningProvider;
+import com.feedzai.openml.provider.TrainingMachineLearningProvider;
+import com.feedzai.openml.provider.descriptor.MLAlgorithmDescriptor;
+import com.feedzai.openml.util.algorithm.MLAlgorithmEnum;
+import com.google.auto.service.AutoService;
+
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Feedzai OpenML {@link MachineLearningProvider} for XGBoost, backed by the native {@code xgboost4j} JVM
+ * package.
+ *
+ * The provider is discovered by Pulse through the standard Java {@link java.util.ServiceLoader}
+ * mechanism (via {@link AutoService}), so no changes to Pulse core are required to make it available -
+ * only adding this module to the runtime classpath.
+ *
+ * @since 1.0.0
+ */
+@AutoService(MachineLearningProvider.class)
+public class XgboostModelProvider implements TrainingMachineLearningProvider {
+
+ /**
+ * The reported name of this provider.
+ */
+ public static final String PROVIDER_NAME = "XGBoost";
+
+ @Override
+ public String getName() {
+ return PROVIDER_NAME;
+ }
+
+ @Override
+ public Set getAlgorithms() {
+ return MLAlgorithmEnum.getDescriptors(XgboostAlgorithms.values());
+ }
+
+ @Override
+ public Optional getModelCreator(final String algorithmName) {
+ return MLAlgorithmEnum.getByName(XgboostAlgorithms.values(), algorithmName)
+ .map(algorithm -> new XgboostModelCreator());
+ }
+}
diff --git a/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostSchemaUtils.java b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostSchemaUtils.java
new file mode 100644
index 00000000..92d22845
--- /dev/null
+++ b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/XgboostSchemaUtils.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2026 Feedzai
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.feedzai.openml.provider.xgboost;
+
+import com.feedzai.openml.data.Instance;
+import com.feedzai.openml.data.schema.DatasetSchema;
+
+/**
+ * Shared helpers to turn Pulse {@link Instance}s into the flat {@code float[]} feature vectors XGBoost
+ * expects.
+ *
+ * Why this is shared between scoring and training: XGBoost is purely numeric and positional -
+ * it has no notion of feature names or categorical domains. Therefore the exact same column ordering and
+ * encoding must be used when a model is trained and when it is later scored. Centralizing the feature
+ * vector construction here guarantees that parity: both {@link XgboostModelCreator} (training) and
+ * {@link XgboostClassificationModel} (scoring) build rows through this class.
+ *
+ *
Categorical fields arrive already encoded as {@code double} indices in the {@link Instance} (Pulse's
+ * standard encoding), so they are copied as-is - identical to the LightGBM provider's behavior.
+ *
+ * @since 1.0.0
+ */
+final class XgboostSchemaUtils {
+
+ /**
+ * This class is not meant to be instantiated.
+ */
+ private XgboostSchemaUtils() {
+ }
+
+ /**
+ * The number of predictive (non-target) features described by the schema.
+ *
+ * @param schema The dataset schema.
+ * @return The number of predictive features.
+ */
+ static int numFeatures(final DatasetSchema schema) {
+ return schema.getPredictiveFields().size();
+ }
+
+ /**
+ * Builds the feature vector for a single {@link Instance}, in schema field order, skipping the target
+ * field if one is present.
+ *
+ * @param instance The instance to convert.
+ * @param schema The dataset schema the instance conforms to.
+ * @return A dense {@code float[]} with one entry per predictive feature.
+ */
+ static float[] featureRow(final Instance instance, final DatasetSchema schema) {
+ final int numFields = schema.getFieldSchemas().size();
+ final int targetIndex = schema.getTargetIndex().orElse(-1);
+ final float[] row = new float[numFeatures(schema)];
+
+ int featureIdx = 0;
+ for (int fieldIdx = 0; fieldIdx < numFields; fieldIdx++) {
+ if (fieldIdx == targetIndex) {
+ continue;
+ }
+ row[featureIdx++] = (float) instance.getValue(fieldIdx);
+ }
+ return row;
+ }
+}
diff --git a/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/package-info.java b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/package-info.java
new file mode 100644
index 00000000..f8a2da0f
--- /dev/null
+++ b/openml-xgboost/src/main/java/com/feedzai/openml/provider/xgboost/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2026 Feedzai
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+/**
+ * OpenML XGBoost provider, backed by the native {@code xgboost4j} JVM package.
+ */
+package com.feedzai.openml.provider.xgboost;
diff --git a/openml-xgboost/src/test/java/com/feedzai/openml/provider/xgboost/XgboostModelProviderTest.java b/openml-xgboost/src/test/java/com/feedzai/openml/provider/xgboost/XgboostModelProviderTest.java
new file mode 100644
index 00000000..5f5c0f62
--- /dev/null
+++ b/openml-xgboost/src/test/java/com/feedzai/openml/provider/xgboost/XgboostModelProviderTest.java
@@ -0,0 +1,187 @@
+/*
+ * Copyright 2026 Feedzai
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.feedzai.openml.provider.xgboost;
+
+import com.feedzai.openml.data.Dataset;
+import com.feedzai.openml.data.schema.DatasetSchema;
+import com.feedzai.openml.mocks.MockDataset;
+import com.feedzai.openml.mocks.MockInstance;
+import com.feedzai.openml.provider.descriptor.MLAlgorithmDescriptor;
+import com.feedzai.openml.provider.descriptor.fieldtype.ParamValidationError;
+import com.feedzai.openml.provider.exception.ModelTrainingException;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.offset;
+
+/**
+ * Round-trip tests for the XGBoost provider: train (in-process, no Spark) -> export -> load -> score.
+ *
+ *
These tests exercise the native {@code xgboost4j} library, validating that the provider works
+ * end-to-end on the host architecture (including ARM / Apple Silicon).
+ *
+ * @since 1.0.0
+ */
+public class XgboostModelProviderTest {
+
+ /**
+ * Binary target nominal values.
+ */
+ private static final Set TARGET_VALUES = ImmutableSet.of("false", "true");
+
+ /**
+ * Number of predictive features in the test schema.
+ */
+ private static final int NUM_FEATURES = 4;
+
+ /**
+ * Schema used across the tests (4 numeric features + binary categorical target).
+ */
+ private static DatasetSchema schema;
+
+ /**
+ * Sets up the shared schema.
+ */
+ @BeforeClass
+ public static void setUp() {
+ schema = MockDataset.generateDefaultSchema(TARGET_VALUES, NUM_FEATURES);
+ }
+
+ /**
+ * Valid training parameters.
+ *
+ * @return The parameters map.
+ */
+ private static Map trainParams() {
+ return ImmutableMap.of(
+ XgboostDescriptorUtil.OBJECTIVE_PARAMETER_NAME, "binary:logistic",
+ XgboostDescriptorUtil.NUM_ROUND_PARAMETER_NAME, "10",
+ "max_depth", "3",
+ "eta", "0.3",
+ XgboostDescriptorUtil.NTHREAD_PARAMETER_NAME, "1"
+ );
+ }
+
+ /**
+ * The provider exposes the XGBoost algorithm and resolves its creator by name.
+ */
+ @Test
+ public void providerExposesXgboostAlgorithm() {
+ final XgboostModelProvider provider = new XgboostModelProvider();
+
+ assertThat(provider.getName()).isEqualTo("XGBoost");
+ assertThat(provider.getAlgorithms())
+ .extracting(MLAlgorithmDescriptor::getAlgorithmName)
+ .contains("XGBoost Binary Classifier");
+ assertThat(provider.getModelCreator("XGBoost Binary Classifier")).isPresent();
+ assertThat(provider.getModelCreator("Non Existing Algorithm")).isEmpty();
+ }
+
+ /**
+ * Valid fit parameters produce no validation errors.
+ *
+ * @throws Exception If the temporary directory cannot be created.
+ */
+ @Test
+ public void validateForFitAcceptsValidParams() throws Exception {
+ final Path tmpDir = Files.createTempDirectory("xgb_fit_validation_");
+ final List errors =
+ new XgboostModelCreator().validateForFit(tmpDir, schema, trainParams());
+
+ assertThat(errors).isEmpty();
+ }
+
+ /**
+ * Trains a model in-process, then scores an instance: the class distribution must be a valid
+ * probability distribution and {@code classify} must return the arg-max class.
+ *
+ * @throws Exception If training/scoring fails.
+ */
+ @Test
+ public void trainsAndScoresInProcess() throws Exception {
+ final XgboostModelCreator creator = new XgboostModelCreator();
+ final Dataset trainDataset = new MockDataset(schema, 200, new Random(0));
+
+ final XgboostClassificationModel model = creator.fit(trainDataset, new Random(0), trainParams());
+
+ final MockInstance instance = new MockInstance(schema, new Random(7));
+ final double[] distribution = model.getClassDistribution(instance);
+
+ assertThat(distribution).hasSize(TARGET_VALUES.size());
+ assertThat(distribution[0] + distribution[1]).isCloseTo(1.0, offset(1e-6));
+ assertThat(distribution[0]).isBetween(0.0, 1.0);
+ assertThat(distribution[1]).isBetween(0.0, 1.0);
+
+ final int classIndex = model.classify(instance);
+ assertThat(classIndex).isBetween(0, 1);
+ assertThat(distribution[classIndex]).isGreaterThanOrEqualTo(distribution[1 - classIndex]);
+
+ model.close();
+ }
+
+ /**
+ * A model saved to disk and reloaded produces identical scores (export -> load round-trip).
+ *
+ * @throws Exception If training/saving/loading fails.
+ */
+ @Test
+ public void savedModelReloadsWithIdenticalScores() throws Exception {
+ final XgboostModelCreator creator = new XgboostModelCreator();
+ final Dataset trainDataset = new MockDataset(schema, 200, new Random(1));
+
+ final XgboostClassificationModel trainedModel = creator.fit(trainDataset, new Random(1), trainParams());
+
+ final MockInstance instance = new MockInstance(schema, new Random(11));
+ final double[] originalDistribution = trainedModel.getClassDistribution(instance);
+
+ final Path saveDir = Files.createTempDirectory("xgb_save_");
+ assertThat(trainedModel.save(saveDir, "reloaded")).isTrue();
+ trainedModel.close();
+
+ final XgboostClassificationModel reloadedModel = creator.loadModel(saveDir, schema);
+ final double[] reloadedDistribution = reloadedModel.getClassDistribution(instance);
+
+ assertThat(reloadedDistribution).containsExactly(originalDistribution, offset(1e-9));
+
+ reloadedModel.close();
+ }
+
+ /**
+ * Training on an empty dataset raises a {@link ModelTrainingException}.
+ */
+ @Test
+ public void trainingOnEmptyDatasetThrows() {
+ final XgboostModelCreator creator = new XgboostModelCreator();
+ final Dataset emptyDataset = new MockDataset(schema, 0, new Random(0));
+
+ assertThatThrownBy(() -> creator.fit(emptyDataset, new Random(0), trainParams()))
+ .isInstanceOf(ModelTrainingException.class)
+ .hasMessageContaining("empty");
+ }
+}
diff --git a/openml-xgboost/src/test/resources/logback-test.xml b/openml-xgboost/src/test/resources/logback-test.xml
new file mode 100644
index 00000000..d3498ba5
--- /dev/null
+++ b/openml-xgboost/src/test/resources/logback-test.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{0} - %msg%n
+
+
+
+
+
+
+
diff --git a/pom.xml b/pom.xml
index a6a71beb..a193ffff 100644
--- a/pom.xml
+++ b/pom.xml
@@ -30,6 +30,7 @@
openml-h2o
openml-java-utils
openml-lightgbm
+ openml-xgboost
Java OpenML Main