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: + *

+ * + * @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