> getCandidateParameters() { return null; }
+
+ default double getCandidateScore(int i) { return Double.NaN; }
+
+ default double getClassifierScore() { return Double.NaN; }
+
+ default int getNumberOfCandidates() { return 0; }
+
+ /**
+ * Number of candidates actually trained and evaluated in the current
+ * evaluation window. Methods that keep a fixed pool simply report the pool
+ * size; methods with a "max capacity pool, active prefix" design report the
+ * prefix length.
+ */
+ default int getActiveCandidates() { return getNumberOfCandidates(); }
+
+ /**
+ * Request that only {@code n} candidates be trained and evaluated from the
+ * next evaluation window on. Implementations must clamp {@code n} to
+ * {@code [1, poolCapacity]} and must only let the change take effect at a
+ * window boundary, so that all scores compared inside one window come from
+ * candidates that saw the same instances. Returns the granted value, which
+ * may differ from {@code n}.
+ *
+ * Pushed by an external controller that sizes the candidate budget. The
+ * default is a no-op so that existing implementers compile unchanged.
+ */
+ default int setActiveCandidates(int n) { return getActiveCandidates(); }
+
+ default long getStatesEvaluatedCount() { return 0; }
+
+ default int getEvaluationInstancesCount() { return 0; }
+
+ default int getPeriodicity() { return Integer.MAX_VALUE; }
+
+ default Classifier getMainClassifier() { return null; }
+
+ default String getConfigurationFile() { return null; }
+
+ /**
+ * Restrict optimisation to a subset of hyperparameters. {@code mask[k]} is
+ * {@code true} when parameter k (in search space order; a categorical
+ * counts as a single entry) should keep being optimised, and {@code false}
+ * when it must be frozen to the current incumbent ("best configuration")
+ * value. A {@code null} mask (the default) means optimise every parameter,
+ * preserving the unrestricted behaviour.
+ */
+ default void setOptimizableParameters(boolean[] mask) {}
+
+ /**
+ * Overwrite the frozen dimensions of {@code params} (those with
+ * {@code mask[k] == false}) with the corresponding value from
+ * {@code incumbent}. Optimised dimensions are left untouched. Shared by all
+ * HPO methods so that frozen hyperparameters carry the real best-configuration
+ * value rather than a placeholder - important for surrogate-based methods
+ * (e.g. the Bayesian Stream Tuner) whose feature vector must stay consistent.
+ */
+ static void freezeToIncumbent(boolean[] mask, ArrayList params,
+ ArrayList incumbent) {
+ if (mask == null || params == null || incumbent == null) return;
+ int n = Math.min(params.size(), Math.min(incumbent.size(), mask.length));
+ for (int k = 0; k < n; k++) {
+ if (mask[k]) continue;
+ Parameter dst = params.get(k);
+ Parameter src = incumbent.get(k);
+ if (dst.type != src.type) continue;
+ switch (dst.type) {
+ case Parameter.TYPE_INT: ((IntParameter) dst).value = ((IntParameter) src).value; break;
+ case Parameter.TYPE_DOUBLE: ((DoubleParameter) dst).value = ((DoubleParameter) src).value; break;
+ case Parameter.TYPE_CATEGORICAL: ((CategoricalParameter) dst).active = ((CategoricalParameter) src).active; break;
+ }
+ }
+ }
+}
diff --git a/moa/src/main/java/moa/classifiers/AutoML/MESSPTClassifier.java b/moa/src/main/java/moa/classifiers/AutoML/MESSPTClassifier.java
new file mode 100644
index 000000000..c60bf65d1
--- /dev/null
+++ b/moa/src/main/java/moa/classifiers/AutoML/MESSPTClassifier.java
@@ -0,0 +1,680 @@
+/*
+ * MESSPTClassifier.java
+ * Copyright (C) 2026 University of Waikato, Hamilton, New Zealand
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package moa.classifiers.AutoML;
+
+import com.github.javacliparser.FileOption;
+import com.github.javacliparser.FlagOption;
+import com.github.javacliparser.FloatOption;
+import com.github.javacliparser.IntOption;
+import com.github.javacliparser.MultiChoiceOption;
+import com.github.javacliparser.StringOption;
+import com.yahoo.labs.samoa.instances.Instance;
+import moa.capabilities.CapabilitiesHandler;
+import moa.classifiers.AbstractClassifier;
+import moa.classifiers.AutoML.Parameters.*;
+import moa.classifiers.AutoML.space.ConfigurationSpace;
+import moa.classifiers.AutoML.space.LearnerConfigurator;
+import moa.classifiers.AutoML.space.ParameterSpec;
+import moa.classifiers.Classifier;
+import moa.classifiers.core.driftdetection.ChangeDetector;
+import moa.classifiers.MultiClassClassifier;
+import moa.core.InstanceExample;
+import moa.core.Measurement;
+import moa.core.SizeOf;
+import moa.evaluation.BasicClassificationPerformanceEvaluator;
+import moa.options.ClassOption;
+
+import java.io.Serializable;
+import java.util.*;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Differential-evolution search over a streaming hyperparameter space: a
+ * population of candidates is trained in parallel and each generation replaces
+ * a member by its DE/best/1 trial point when the trial scores higher over the
+ * evaluation window.
+ *
+ * Candidates are configured through {@link LearnerConfigurator}, i.e. by
+ * writing MOA {@link com.github.javacliparser.Option}s, so any MOA classifier
+ * can be tuned as shipped.
+ *
+ *
See details in:
Antonio R. Moya, Bruno Veloso, Joao Gama, Sebastian
+ * Ventura. Improving hyper-parameter self-tuning for data streams by adapting
+ * an evolutionary approach. In Data Mining and Knowledge Discovery,
+ * 38(3):1289-1315, Springer, 2023.
+ *
+ * @author Daniel Nowak Assis (daniel dot nowak-assis at lip6 dot fr)
+ */
+public class MESSPTClassifier extends AbstractClassifier implements MultiClassClassifier,
+ CapabilitiesHandler, Serializable, HPOMethod {
+
+ public FileOption configurationFileOption = new FileOption("configurationFile", 'f',
+ "Search space in JSON format.", null, ".json", false);
+
+ public StringOption searchSpaceOption = new StringOption("searchSpace", 's',
+ "Search space as inline JSON. Takes precedence over configurationFile when set,"
+ + " so that a caller holding the space in memory need not write a file.", "");
+
+ public IntOption periodicityOption = new IntOption("periodicity", 'g',
+ "Number of instances between DE updates.", 1000, 1, Integer.MAX_VALUE);
+
+ public IntOption populationSizeOption = new IntOption("populationSize", 'p',
+ "Number of candidate models in the population.", 4, 2, Integer.MAX_VALUE);
+
+ public FloatOption convergenceSphereOption = new FloatOption("convergenceSphere", 'c',
+ "Convergence threshold: squared distance between best params across generations.", 0.001, 0.0, Double.MAX_VALUE);
+
+ public FloatOption initialMutationFactorOption = new FloatOption("initialMutationFactor", 'F',
+ "Initial DE mutation factor.", 0.5, 0.0, 1.0);
+
+ public FloatOption initialCrossoverRateOption = new FloatOption("initialCrossoverRate", 'C',
+ "Initial DE crossover rate.", 0.5, 0.0, 1.0);
+
+ public FloatOption augmentationStepOption = new FloatOption("augmentationStep", 'a',
+ "Step by which the mutation factor decreases and the crossover rate increases each generation.", 0.025, 0.0, 1.0);
+
+ public MultiChoiceOption metricOption = new MultiChoiceOption("metric", 'm',
+ "Metric to optimize the model.", MetricUtils.METRIC_NAMES, MetricUtils.METRIC_DESCRIPTIONS, 4);
+
+ public FlagOption resetModelsOption = new FlagOption("resetModels", 'r',
+ "Create fresh classifiers for trial points instead of warm-starting from best.");
+
+ public FlagOption verboseOption = new FlagOption("verbose", 'v',
+ "Print DE events to stdout.");
+
+ public FlagOption driftDetectionOption = new FlagOption("driftDetection", 'd',
+ "Enable drift detection on the incumbent's prediction error.");
+
+ public ClassOption driftDetectorOption = new ClassOption("driftDetector", 'D',
+ "Change detector to use when drift detection is enabled; on a detected"
+ + " drift the whole search is reinitialized from the search space.",
+ ChangeDetector.class, "ADWINChangeDetector");
+
+ public IntOption numberOfJobsOption = new IntOption("numberOfJobs", 'j',
+ "Total number of concurrent jobs used for processing (-1 = as much as possible, 0 = do not use multithreading)",
+ 1, -1, Integer.MAX_VALUE);
+
+ protected static final int SINGLE_THREAD = 0;
+
+ // ========== INNER CLASS ==========
+
+ protected static class PopulationEntry implements Serializable {
+ Classifier model;
+ BasicClassificationPerformanceEvaluator evaluator;
+ ArrayList params;
+
+ PopulationEntry(Classifier model, BasicClassificationPerformanceEvaluator evaluator,
+ ArrayList params) {
+ this.model = model;
+ this.evaluator = evaluator;
+ this.params = params;
+ }
+
+ double getMetric(int metricIndex) {
+ return MetricUtils.getScore(evaluator.getPerformanceMeasurements(), metricIndex);
+ }
+ }
+
+ // ========== FIELDS ==========
+
+ protected PopulationEntry[] population;
+ protected ArrayList oldBestParams;
+
+ protected ConfigurationSpace space;
+
+ /** Boundary through which every learner is configured. */
+ protected LearnerConfigurator configurator;
+
+ protected long instanceCount;
+ protected int evaluationInstances;
+ protected boolean converged;
+
+ protected double mutationFactor;
+ protected double crossoverRate;
+
+ protected boolean[] optimizeMask;
+
+ /** Detector on the incumbent's error; {@code null} unless drift detection is on. */
+ protected ChangeDetector driftDetector;
+
+ /** Cumulative number of drifts signalled over the run; not reset by a restart. */
+ protected long driftsDetected;
+
+ /**
+ * Pool training the population concurrently; {@code null} when running
+ * single-threaded. Transient because an executor cannot be serialized, so it
+ * is (re)created on demand by {@link #initExecutor()}.
+ */
+ protected transient ExecutorService executor;
+
+ @Override
+ public void setOptimizableParameters(boolean[] mask) { this.optimizeMask = mask; }
+
+ // ========== LIFECYCLE ==========
+
+ @Override
+ public boolean isRandomizable() {
+ return true;
+ }
+
+ /**
+ * Creates the training pool on first use, and again after deserialization.
+ * A pool larger than the batch trained per instance would leave threads idle,
+ * so the requested job count is capped at the population size.
+ */
+ protected void initExecutor() {
+ if (this.executor != null) return;
+ int numberOfJobs = this.numberOfJobsOption.getValue() == -1
+ ? Runtime.getRuntime().availableProcessors()
+ : this.numberOfJobsOption.getValue();
+ numberOfJobs = Math.min(numberOfJobs, this.populationSizeOption.getValue());
+ // SINGLE_THREAD and requesting a single thread are equivalent: training
+ // then happens in-place and this.executor stays null.
+ if (numberOfJobs != SINGLE_THREAD && numberOfJobs != 1) {
+ // Daemon threads: a live pool must not keep the JVM alive after the
+ // task that ran this learner has finished.
+ this.executor = Executors.newFixedThreadPool(numberOfJobs, runnable -> {
+ Thread thread = new Thread(runnable);
+ thread.setDaemon(true);
+ return thread;
+ });
+ }
+ }
+
+ @Override
+ public void cleanThreads() {
+ if (this.executor != null) {
+ this.executor.shutdownNow();
+ this.executor = null;
+ }
+ }
+
+ @Override
+ public void resetLearningImpl() {
+ cleanThreads(); // shut down any pool from a previous reset before creating a new one
+ converged = false;
+ instanceCount = 0;
+ evaluationInstances = 0;
+ oldBestParams = null;
+ mutationFactor = initialMutationFactorOption.getValue();
+ crossoverRate = initialCrossoverRateOption.getValue();
+
+ driftDetector = driftDetectionOption.isSet()
+ ? ((ChangeDetector) getPreparedClassOption(driftDetectorOption)).copy()
+ : null;
+
+ setConfigurations();
+ initializePopulation();
+
+ }
+
+ @Override
+ public void setConfigurations() {
+ try {
+ this.space = ConfigurationSpace.resolve(configurationFileOption.getValue(), searchSpaceOption.getValue());
+ this.configurator = new LearnerConfigurator(this.space);
+ this.configurator.validate();
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not set up " + getClass().getSimpleName()
+ + " from " + ConfigurationSpace.describeSource(configurationFileOption.getValue(), searchSpaceOption.getValue()) + ": " + e.getMessage(), e);
+ }
+ }
+
+ private void initializePopulation() {
+ int n = populationSizeOption.getValue();
+ population = new PopulationEntry[n];
+ for (int i = 0; i < n; i++) {
+ ArrayList params = createRandomParams();
+ Classifier model = createModelWithParams(params);
+ population[i] = new PopulationEntry(model, newEvaluator(), params);
+ }
+ if (verboseOption.isSet())
+ System.out.println("MESSPTClassifier: Initialized population with " + n + " models");
+ }
+
+ private ArrayList createRandomParams() {
+ ArrayList params = new ArrayList<>();
+ for (ParameterSpec spec : this.space.parameters) {
+ String name = spec.name;
+ switch (spec.type) {
+ case ParameterSpec.TYPE_INT: {
+ int[] range = {(int) spec.range[0], (int) spec.range[1]};
+ int value = classifierRandom.nextInt(range[1] - range[0] + 1) + range[0];
+ params.add(new IntParameter(name, value, range, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case ParameterSpec.TYPE_DOUBLE: {
+ double[] range = {spec.range[0], spec.range[1]};
+ double value = range[0] + classifierRandom.nextDouble() * (range[1] - range[0]);
+ params.add(new DoubleParameter(name, value, range, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case ParameterSpec.TYPE_CATEGORICAL: {
+ String[] values = spec.values;
+ int active = classifierRandom.nextInt(values.length);
+ params.add(new CategoricalParameter(name, values, active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return params;
+ }
+
+ /** A model built from scratch at {@code params}. */
+ private Classifier createModelWithParams(ArrayList params) {
+ return this.configurator.instantiate(params, this.classifierRandom.nextInt());
+ }
+
+ /**
+ * Reconfigure a warm-started model in place, returning the model to use.
+ * Falls back to rebuilding when the space contains a hyperparameter the
+ * learner only reads at construction time, which by definition cannot take
+ * effect on a model that is already training.
+ */
+ private Classifier applyParamsToModel(Classifier model, ArrayList params) {
+ if (!this.configurator.canApplyLive()) {
+ return this.configurator.instantiate(params, this.classifierRandom.nextInt());
+ }
+ this.configurator.applyLive(model, params);
+ return model;
+ }
+
+ private BasicClassificationPerformanceEvaluator newEvaluator() {
+ BasicClassificationPerformanceEvaluator eval = new BasicClassificationPerformanceEvaluator();
+ MetricUtils.configure(eval);
+ return eval;
+ }
+
+ // ========== TRAINING ==========
+
+ @Override
+ public double[] getVotesForInstance(Instance inst) {
+ return population[0].model.getVotesForInstance(inst);
+ }
+
+ @Override
+ public void trainOnInstanceImpl(Instance inst) {
+ double[] incumbentVotes = driftDetector != null ? getVotesForInstance(inst) : null;
+ initExecutor();
+ instanceCount++;
+ evaluationInstances++;
+ InstanceExample example = new InstanceExample(inst);
+
+ if (converged) {
+ trainConverged(inst, example);
+ } else {
+ trainNotConverged(inst, example);
+ }
+
+ if (incumbentVotes != null) checkDrift(inst, incumbentVotes);
+ }
+
+ private void trainConverged(Instance inst, InstanceExample example) {
+ PopulationEntry best = population[0];
+ double[] votes = best.model.getVotesForInstance(inst);
+ best.evaluator.addResult(example, votes);
+ best.model.trainOnInstance(inst);
+ }
+
+ private void trainNotConverged(Instance inst, InstanceExample example) {
+ Collection trainers = this.executor == null
+ ? null : new ArrayList();
+
+ for (PopulationEntry entry : population) {
+ double[] votes = entry.model.getVotesForInstance(inst);
+ entry.evaluator.addResult(example, votes);
+
+ // Every individual owns its model, hence no synchronization here.
+ if (trainers == null) entry.model.trainOnInstance(inst);
+ else trainers.add(new TrainingRunnable(entry.model, inst));
+ }
+
+ if (trainers != null) {
+ try {
+ this.executor.invokeAll(trainers);
+ } catch (InterruptedException ex) {
+ throw new RuntimeException("Could not call invokeAll() on training threads.");
+ }
+ }
+
+ if (evaluationInstances >= periodicityOption.getValue()) {
+ evaluationInstances = 0;
+ updatePopulation();
+
+ if (checkConvergence()) {
+ if (verboseOption.isSet())
+ System.out.println("MESSPTClassifier: Converged at instance " + instanceCount);
+ converged = true;
+ } else {
+ mutationFactor = Math.max(0.0, mutationFactor - augmentationStepOption.getValue());
+ crossoverRate = Math.min(1.0, crossoverRate + augmentationStepOption.getValue());
+ }
+ }
+ }
+
+ // ========== DE UPDATE ==========
+
+ private void updatePopulation() {
+ sortPopulation();
+ oldBestParams = cloneParams(population[0].params);
+
+ int n = population.length;
+ PopulationEntry[] newPopulation = new PopulationEntry[n];
+ newPopulation[0] = population[0]; // elitism: keep best
+
+ for (int i = 1; i < n; i++) {
+ ArrayList trialParams = deMutationBest1(i);
+ ArrayList newParams = crossover(population[i].params, trialParams);
+
+ // Freeze the hyperparameters outside the optimized subset to the elite (best) individual.
+ HPOMethod.freezeToIncumbent(this.optimizeMask, newParams, population[0].params);
+
+ PopulationEntry newEntry;
+ if (resetModelsOption.isSet()) {
+ Classifier model = createModelWithParams(newParams);
+ newEntry = new PopulationEntry(model, newEvaluator(), newParams);
+ } else {
+ Classifier model = population[0].model.copy();
+ LearnerConfigurator.reseedCopy(model, this.classifierRandom.nextInt());
+ model = applyParamsToModel(model, newParams);
+ newEntry = new PopulationEntry(model, newEvaluator(), newParams);
+ }
+ newPopulation[i] = newEntry;
+ }
+
+ population = newPopulation;
+ }
+
+ private void sortPopulation() {
+ int metric = metricOption.getChosenIndex();
+ Arrays.sort(population, (a, b) -> Double.compare(b.getMetric(metric), a.getMetric(metric)));
+ }
+
+ // DE/best/1 mutation: v = best + mutationFactor * (r1 - r2)
+ private ArrayList deMutationBest1(int targetIndex) {
+ // Sample two distinct indices from population, neither being 0 (best) nor targetIndex
+ List pool = new ArrayList<>();
+ for (int i = 1; i < population.length; i++) {
+ if (i != targetIndex) pool.add(i);
+ }
+ Collections.shuffle(pool, classifierRandom);
+ int r1Idx = pool.get(0);
+ int r2Idx = pool.get(1);
+
+ ArrayList best = population[0].params;
+ ArrayList r1 = population[r1Idx].params;
+ ArrayList r2 = population[r2Idx].params;
+
+ ArrayList trial = new ArrayList<>();
+ for (int i = 0; i < best.size(); i++) {
+ Parameter pb = best.get(i);
+ Parameter p1 = r1.get(i);
+ Parameter p2 = r2.get(i);
+ switch (pb.type) {
+ case Parameter.TYPE_INT: {
+ int[] range = ((IntParameter) pb).range;
+ double mutated = ((IntParameter) pb).value + mutationFactor * (((IntParameter) p1).value - ((IntParameter) p2).value);
+ int val = clampInt((int) Math.round(mutated), range[0], range[1]);
+ trial.add(new IntParameter(pb.name, val, range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_DOUBLE: {
+ double[] range = ((DoubleParameter) pb).range;
+ double mutated = ((DoubleParameter) pb).value + mutationFactor * (((DoubleParameter) p1).value - ((DoubleParameter) p2).value);
+ double val = clampDouble(mutated, range[0], range[1]);
+ trial.add(new DoubleParameter(pb.name, val, range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_CATEGORICAL: {
+ CategoricalParameter cp = (CategoricalParameter) pb;
+ int active = classifierRandom.nextInt(cp.values.length);
+ trial.add(new CategoricalParameter(pb.name, cp.values.clone(), active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return trial;
+ }
+
+ private ArrayList crossover(ArrayList target, ArrayList trial) {
+ ArrayList result = new ArrayList<>();
+ for (int i = 0; i < target.size(); i++) {
+ Parameter pt = target.get(i);
+ Parameter pv = trial.get(i);
+ boolean useTrial = classifierRandom.nextDouble() < crossoverRate;
+ switch (pt.type) {
+ case Parameter.TYPE_INT: {
+ IntParameter ip = (IntParameter) (useTrial ? pv : pt);
+ result.add(new IntParameter(pt.name, ip.value, ((IntParameter) pt).range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_DOUBLE: {
+ DoubleParameter dp = (DoubleParameter) (useTrial ? pv : pt);
+ result.add(new DoubleParameter(pt.name, dp.value, ((DoubleParameter) pt).range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_CATEGORICAL: {
+ CategoricalParameter ct = (CategoricalParameter) pt;
+ CategoricalParameter cv = (CategoricalParameter) pv;
+ int active = useTrial ? cv.active : ct.active;
+ result.add(new CategoricalParameter(pt.name, ct.values.clone(), active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return result;
+ }
+
+ private boolean checkConvergence() {
+ if (oldBestParams == null) return false;
+ ArrayList current = population[0].params;
+ double distSq = 0;
+ for (int i = 0; i < oldBestParams.size(); i++) {
+ Parameter op = oldBestParams.get(i);
+ Parameter cp = current.get(i);
+ if (op.type == 0) {
+ double diff = ((IntParameter) op).value - ((IntParameter) cp).value;
+ distSq += diff * diff;
+ } else if (op.type == 1) {
+ double diff = ((DoubleParameter) op).value - ((DoubleParameter) cp).value;
+ distSq += diff * diff;
+ }
+ }
+ double threshold = convergenceSphereOption.getValue();
+ return distSq < threshold * threshold;
+ }
+
+ // ========== HELPERS ==========
+
+ private ArrayList cloneParams(ArrayList source) {
+ ArrayList copy = new ArrayList<>();
+ for (Parameter p : source) {
+ switch (p.type) {
+ case Parameter.TYPE_INT: {
+ IntParameter ip = (IntParameter) p;
+ copy.add(new IntParameter(ip.name, ip.value, ip.range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_DOUBLE: {
+ DoubleParameter dp = (DoubleParameter) p;
+ copy.add(new DoubleParameter(dp.name, dp.value, dp.range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_CATEGORICAL: {
+ CategoricalParameter cp = (CategoricalParameter) p;
+ copy.add(new CategoricalParameter(cp.name, cp.values.clone(), cp.active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return copy;
+ }
+
+ private int clampInt(int val, int min, int max) {
+ return Math.max(min, Math.min(max, val));
+ }
+
+ private double clampDouble(double val, double min, double max) {
+ return Math.max(min, Math.min(max, val));
+ }
+
+ private int argmax(double[] arr) {
+ int best = 0;
+ for (int i = 1; i < arr.length; i++)
+ if (arr[i] > arr[best]) best = i;
+ return best;
+ }
+
+ @Override
+ public void checkParameterChange() {}
+
+ @Override
+ public void swapClassifiers(int bestPerforming) {}
+
+ @Override
+ public void deepCopyList(int bestPerforming) {}
+
+ @Override
+ public void changeStateParameter(Parameter parameter, int index) {}
+
+ // ========== MOA INTERFACE ==========
+
+ @Override
+ protected Measurement[] getModelMeasurementsImpl() {
+ ArrayList measurements = new ArrayList<>();
+ measurements.add(new Measurement("driftsDetected", driftsDetected));
+ for (Measurement m : population[0].model.getModelMeasurements())
+ measurements.add(m);
+ measurements.add(new Measurement("mutationFactor", mutationFactor));
+ measurements.add(new Measurement("crossoverRate", crossoverRate));
+ for (Parameter p : population[0].params) {
+ switch (p.type) {
+ case Parameter.TYPE_INT:
+ measurements.add(new Measurement(p.name, ((IntParameter) p).value));
+ break;
+ case Parameter.TYPE_DOUBLE:
+ measurements.add(new Measurement(p.name, ((DoubleParameter) p).value));
+ break;
+ case Parameter.TYPE_CATEGORICAL:
+ measurements.add(new Measurement(p.name, ((CategoricalParameter) p).active));
+ break;
+ }
+ }
+ return measurements.toArray(new Measurement[0]);
+ }
+
+ @Override
+ public void getModelDescription(StringBuilder out, int indent) {
+ }
+
+ @Override
+ public long measureByteSize() {
+ long size = SizeOf.sizeOf(this);
+ for (PopulationEntry entry : population)
+ size += entry.model.measureByteSize();
+ return size;
+ }
+
+ @Override
+ public double getCandidateScore(int i) {
+ return population[i].getMetric(metricOption.getChosenIndex());
+ }
+
+ @Override
+ public double getClassifierScore() {
+ return population[0].getMetric(metricOption.getChosenIndex());
+ }
+
+ @Override
+ public int getNumberOfCandidates() { return populationSizeOption.getValue(); }
+
+ @Override
+ public long getStatesEvaluatedCount() { return instanceCount / periodicityOption.getValue(); }
+
+ @Override
+ public int getEvaluationInstancesCount() { return evaluationInstances; }
+
+ @Override
+ public int getPeriodicity() { return periodicityOption.getValue(); }
+
+ @Override
+ public Classifier getMainClassifier() { return population[0].model; }
+
+ @Override
+ public ArrayList getReferenceParameters() {
+ return (population != null && population.length > 0) ? population[0].params : null;
+ }
+
+ @Override
+ public String getConfigurationFile() { return this.configurationFileOption.getValue();}
+
+
+ @Override
+ public ArrayList> getCandidateParameters() {
+ ArrayList> list = new ArrayList<>();
+ if (population != null) {
+ for (PopulationEntry e : population) {
+ list.add(e.params);
+ }
+ }
+ return list;
+ }
+
+
+ /**
+ * Feeds the incumbent's prediction error to the change detector and, when a
+ * drift is signalled, reinitializes the whole search from the search space.
+ * The signal is the 0/1 misclassification indicator.
+ */
+ private void checkDrift(Instance inst, double[] incumbentVotes) {
+ driftDetector.input(argmax(incumbentVotes) != (int) inst.classValue() ? 1.0 : 0.0);
+ if (driftDetector.getChange()) {
+ driftsDetected++;
+ if (verboseOption.isSet())
+ System.out.println("MESSPT: Drift detected at instance " + instanceCount
+ + ", reinitializing");
+ resetLearningImpl();
+ }
+ }
+
+ /**
+ * Inner class to assist with the multi-thread execution.
+ */
+ protected class TrainingRunnable implements Runnable, Callable {
+ final private Classifier learner;
+ final private Instance instance;
+
+ public TrainingRunnable(Classifier learner, Instance instance) {
+ this.learner = learner;
+ this.instance = instance;
+ }
+
+ @Override
+ public void run() {
+ this.learner.trainOnInstance(this.instance);
+ }
+
+ @Override
+ public Integer call() {
+ run();
+ return 0;
+ }
+ }
+}
diff --git a/moa/src/main/java/moa/classifiers/AutoML/MESSPTRegressor.java b/moa/src/main/java/moa/classifiers/AutoML/MESSPTRegressor.java
new file mode 100644
index 000000000..7a8314ea4
--- /dev/null
+++ b/moa/src/main/java/moa/classifiers/AutoML/MESSPTRegressor.java
@@ -0,0 +1,673 @@
+/*
+ * MESSPTRegressor.java
+ * Copyright (C) 2026 University of Waikato, Hamilton, New Zealand
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package moa.classifiers.AutoML;
+
+import com.github.javacliparser.FileOption;
+import com.github.javacliparser.FlagOption;
+import com.github.javacliparser.FloatOption;
+import com.github.javacliparser.IntOption;
+import com.github.javacliparser.MultiChoiceOption;
+import com.github.javacliparser.StringOption;
+import com.yahoo.labs.samoa.instances.Instance;
+import moa.capabilities.CapabilitiesHandler;
+import moa.classifiers.AbstractClassifier;
+import moa.classifiers.AutoML.Parameters.*;
+import moa.classifiers.AutoML.space.ConfigurationSpace;
+import moa.classifiers.AutoML.space.LearnerConfigurator;
+import moa.classifiers.AutoML.space.ParameterSpec;
+import moa.classifiers.Classifier;
+import moa.classifiers.core.driftdetection.ChangeDetector;
+import moa.classifiers.Regressor;
+import moa.core.InstanceExample;
+import moa.core.Measurement;
+import moa.core.SizeOf;
+import moa.evaluation.BasicRegressionPerformanceEvaluator;
+import moa.options.ClassOption;
+
+import java.io.Serializable;
+import java.util.*;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Regression counterpart of {@link MESSPTClassifier}: differential evolution
+ * over the streaming hyperparameter space, scored with the regression metrics
+ * of {@link MetricUtils}.
+ *
+ * See details in:
Antonio R. Moya, Bruno Veloso, Joao Gama, Sebastian
+ * Ventura. Improving hyper-parameter self-tuning for data streams by adapting
+ * an evolutionary approach. In Data Mining and Knowledge Discovery,
+ * 38(3):1289-1315, Springer, 2023.
+ *
+ * @author Daniel Nowak Assis (daniel dot nowak-assis at lip6 dot fr)
+ */
+public class MESSPTRegressor extends AbstractClassifier implements Regressor,
+ CapabilitiesHandler, Serializable, HPOMethod {
+
+ public FileOption configurationFileOption = new FileOption("configurationFile", 'f',
+ "Search space in JSON format.", null, ".json", false);
+
+ public StringOption searchSpaceOption = new StringOption("searchSpace", 's',
+ "Search space as inline JSON. Takes precedence over configurationFile when set,"
+ + " so that a caller holding the space in memory need not write a file.", "");
+
+ public IntOption periodicityOption = new IntOption("periodicity", 'g',
+ "Number of instances between DE updates.", 1000, 1, Integer.MAX_VALUE);
+
+ public IntOption populationSizeOption = new IntOption("populationSize", 'p',
+ "Number of candidate models in the population.", 4, 2, Integer.MAX_VALUE);
+
+ public FloatOption convergenceSphereOption = new FloatOption("convergenceSphere", 'c',
+ "Convergence threshold: squared distance between best params across generations.", 0.001, 0.0, Double.MAX_VALUE);
+
+ public FloatOption initialMutationFactorOption = new FloatOption("initialMutationFactor", 'F',
+ "Initial DE mutation factor.", 0.5, 0.0, 1.0);
+
+ public FloatOption initialCrossoverRateOption = new FloatOption("initialCrossoverRate", 'C',
+ "Initial DE crossover rate.", 0.5, 0.0, 1.0);
+
+ public FloatOption augmentationStepOption = new FloatOption("augmentationStep", 'a',
+ "Step by which the mutation factor decreases and the crossover rate increases each generation.", 0.025, 0.0, 1.0);
+
+ public MultiChoiceOption metricOption = new MultiChoiceOption("metric", 'm',
+ "Metric to optimize the model.", MetricUtils.REGRESSION_METRIC_NAMES,
+ MetricUtils.REGRESSION_METRIC_DESCRIPTIONS, 0);
+
+ public FlagOption resetModelsOption = new FlagOption("resetModels", 'r',
+ "Create fresh regressors for trial points instead of warm-starting from best.");
+
+ public FlagOption verboseOption = new FlagOption("verbose", 'v',
+ "Print DE events to stdout.");
+
+ public FlagOption driftDetectionOption = new FlagOption("driftDetection", 'd',
+ "Enable drift detection on the incumbent's prediction error.");
+
+ public ClassOption driftDetectorOption = new ClassOption("driftDetector", 'D',
+ "Change detector to use when drift detection is enabled; on a detected"
+ + " drift the whole search is reinitialized from the search space.",
+ ChangeDetector.class, "ADWINChangeDetector");
+
+ public IntOption numberOfJobsOption = new IntOption("numberOfJobs", 'j',
+ "Total number of concurrent jobs used for processing (-1 = as much as possible, 0 = do not use multithreading)",
+ 1, -1, Integer.MAX_VALUE);
+
+ protected static final int SINGLE_THREAD = 0;
+
+ // ========== INNER CLASS ==========
+
+ protected static class PopulationEntry implements Serializable {
+ Classifier model;
+ BasicRegressionPerformanceEvaluator evaluator;
+ ArrayList params;
+
+ PopulationEntry(Classifier model, BasicRegressionPerformanceEvaluator evaluator,
+ ArrayList params) {
+ this.model = model;
+ this.evaluator = evaluator;
+ this.params = params;
+ }
+
+ double getMetric(int metricIndex) {
+ return MetricUtils.getRegressionScore(evaluator.getPerformanceMeasurements(), metricIndex);
+ }
+ }
+
+ // ========== FIELDS ==========
+
+ protected PopulationEntry[] population;
+ protected ArrayList oldBestParams;
+
+ protected ConfigurationSpace space;
+
+ /** Boundary through which every learner is configured. */
+ protected LearnerConfigurator configurator;
+
+ protected long instanceCount;
+ protected int evaluationInstances;
+ protected boolean converged;
+
+ protected double mutationFactor;
+ protected double crossoverRate;
+
+ protected boolean[] optimizeMask;
+
+ /** Detector on the incumbent's error; {@code null} unless drift detection is on. */
+ protected ChangeDetector driftDetector;
+
+ /** Cumulative number of drifts signalled over the run; not reset by a restart. */
+ protected long driftsDetected;
+
+ /**
+ * Pool training the population concurrently; {@code null} when running
+ * single-threaded. Transient because an executor cannot be serialized, so it
+ * is (re)created on demand by {@link #initExecutor()}.
+ */
+ protected transient ExecutorService executor;
+
+ @Override
+ public void setOptimizableParameters(boolean[] mask) { this.optimizeMask = mask; }
+
+ // ========== LIFECYCLE ==========
+
+ @Override
+ public boolean isRandomizable() {
+ return true;
+ }
+
+ /**
+ * Creates the training pool on first use, and again after deserialization.
+ * A pool larger than the batch trained per instance would leave threads idle,
+ * so the requested job count is capped at the population size.
+ */
+ protected void initExecutor() {
+ if (this.executor != null) return;
+ int numberOfJobs = this.numberOfJobsOption.getValue() == -1
+ ? Runtime.getRuntime().availableProcessors()
+ : this.numberOfJobsOption.getValue();
+ numberOfJobs = Math.min(numberOfJobs, this.populationSizeOption.getValue());
+ // SINGLE_THREAD and requesting a single thread are equivalent: training
+ // then happens in-place and this.executor stays null.
+ if (numberOfJobs != SINGLE_THREAD && numberOfJobs != 1) {
+ // Daemon threads: a live pool must not keep the JVM alive after the
+ // task that ran this learner has finished.
+ this.executor = Executors.newFixedThreadPool(numberOfJobs, runnable -> {
+ Thread thread = new Thread(runnable);
+ thread.setDaemon(true);
+ return thread;
+ });
+ }
+ }
+
+ @Override
+ public void cleanThreads() {
+ if (this.executor != null) {
+ this.executor.shutdownNow();
+ this.executor = null;
+ }
+ }
+
+ @Override
+ public void resetLearningImpl() {
+ cleanThreads(); // shut down any pool from a previous reset before creating a new one
+ converged = false;
+ instanceCount = 0;
+ evaluationInstances = 0;
+ oldBestParams = null;
+ mutationFactor = initialMutationFactorOption.getValue();
+ crossoverRate = initialCrossoverRateOption.getValue();
+
+ driftDetector = driftDetectionOption.isSet()
+ ? ((ChangeDetector) getPreparedClassOption(driftDetectorOption)).copy()
+ : null;
+
+ setConfigurations();
+ initializePopulation();
+
+ }
+
+
+ public void setConfigurations() {
+ try {
+ this.space = ConfigurationSpace.resolve(configurationFileOption.getValue(), searchSpaceOption.getValue());
+ this.configurator = new LearnerConfigurator(this.space);
+ this.configurator.validate();
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not set up " + getClass().getSimpleName()
+ + " from " + ConfigurationSpace.describeSource(configurationFileOption.getValue(), searchSpaceOption.getValue()) + ": " + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void checkParameterChange() {
+
+ }
+
+ @Override
+ public void swapClassifiers(int bestPerforming) {
+
+ }
+
+ @Override
+ public void deepCopyList(int bestPerforming) {
+
+ }
+
+ @Override
+ public void changeStateParameter(Parameter parameter, int index) {
+
+ }
+
+ private void initializePopulation() {
+ int n = populationSizeOption.getValue();
+ population = new PopulationEntry[n];
+ for (int i = 0; i < n; i++) {
+ ArrayList params = createRandomParams();
+ Classifier model = createModelWithParams(params);
+ population[i] = new PopulationEntry(model, newEvaluator(), params);
+ }
+ if (verboseOption.isSet())
+ System.out.println("MESSPTRegressor: Initialized population with " + n + " models");
+ }
+
+ private ArrayList createRandomParams() {
+ ArrayList params = new ArrayList<>();
+ for (ParameterSpec spec : this.space.parameters) {
+ String name = spec.name;
+ switch (spec.type) {
+ case ParameterSpec.TYPE_INT: {
+ int[] range = {(int) spec.range[0], (int) spec.range[1]};
+ int value = classifierRandom.nextInt(range[1] - range[0] + 1) + range[0];
+ params.add(new IntParameter(name, value, range, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case ParameterSpec.TYPE_DOUBLE: {
+ double[] range = {spec.range[0], spec.range[1]};
+ double value = range[0] + classifierRandom.nextDouble() * (range[1] - range[0]);
+ params.add(new DoubleParameter(name, value, range, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case ParameterSpec.TYPE_CATEGORICAL: {
+ String[] values = spec.values;
+ int active = classifierRandom.nextInt(values.length);
+ params.add(new CategoricalParameter(name, values, active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return params;
+ }
+
+ /** A model built from scratch at {@code params}. */
+ private Classifier createModelWithParams(ArrayList params) {
+ return this.configurator.instantiate(params, this.classifierRandom.nextInt());
+ }
+
+ /**
+ * Reconfigure a warm-started model in place, returning the model to use.
+ * Falls back to rebuilding when the space contains a hyperparameter the
+ * learner only reads at construction time, which by definition cannot take
+ * effect on a model that is already training.
+ */
+ private Classifier applyParamsToModel(Classifier model, ArrayList params) {
+ if (!this.configurator.canApplyLive()) {
+ return this.configurator.instantiate(params, this.classifierRandom.nextInt());
+ }
+ this.configurator.applyLive(model, params);
+ return model;
+ }
+
+ private BasicRegressionPerformanceEvaluator newEvaluator() {
+ return new BasicRegressionPerformanceEvaluator();
+ }
+
+ // ========== TRAINING ==========
+
+ @Override
+ public double[] getVotesForInstance(Instance inst) {
+ return population[0].model.getVotesForInstance(inst);
+ }
+
+ @Override
+ public void trainOnInstanceImpl(Instance inst) {
+ double[] incumbentVotes = driftDetector != null ? getVotesForInstance(inst) : null;
+ initExecutor();
+ instanceCount++;
+ evaluationInstances++;
+ InstanceExample example = new InstanceExample(inst);
+
+ trainNotConverged(inst, example);
+
+ if (incumbentVotes != null) checkDrift(inst, incumbentVotes);
+ }
+
+ private void trainConverged(Instance inst, InstanceExample example) {
+ PopulationEntry best = population[0];
+ double[] votes = best.model.getVotesForInstance(inst);
+ best.evaluator.addResult(example, votes);
+ best.model.trainOnInstance(inst);
+ }
+
+ private void trainNotConverged(Instance inst, InstanceExample example) {
+ Collection trainers = this.executor == null
+ ? null : new ArrayList();
+
+ for (PopulationEntry entry : population) {
+ double[] votes = entry.model.getVotesForInstance(inst);
+ entry.evaluator.addResult(example, votes);
+
+ // Every individual owns its model, hence no synchronization here.
+ if (trainers == null) entry.model.trainOnInstance(inst);
+ else trainers.add(new TrainingRunnable(entry.model, inst));
+ }
+
+ if (trainers != null) {
+ try {
+ this.executor.invokeAll(trainers);
+ } catch (InterruptedException ex) {
+ throw new RuntimeException("Could not call invokeAll() on training threads.");
+ }
+ }
+
+ if (evaluationInstances >= periodicityOption.getValue()) {
+ evaluationInstances = 0;
+ updatePopulation();
+
+ if (checkConvergence()) {
+ if (verboseOption.isSet())
+ System.out.println("MESSPTRegressor: Converged at instance " + instanceCount);
+ converged = true;
+ } else {
+ mutationFactor = Math.max(0.0, mutationFactor - augmentationStepOption.getValue());
+ crossoverRate = Math.min(1.0, crossoverRate + augmentationStepOption.getValue());
+ }
+ }
+ }
+
+ // ========== DE UPDATE ==========
+
+ private void updatePopulation() {
+ sortPopulation();
+ oldBestParams = cloneParams(population[0].params);
+
+ int n = population.length;
+ PopulationEntry[] newPopulation = new PopulationEntry[n];
+ newPopulation[0] = population[0]; // elitism: keep best
+
+ for (int i = 1; i < n; i++) {
+ ArrayList trialParams = deMutationBest1(i);
+ ArrayList newParams = crossover(population[i].params, trialParams);
+
+ // Freeze the hyperparameters outside the optimized subset to the elite (best) individual.
+ HPOMethod.freezeToIncumbent(this.optimizeMask, newParams, population[0].params);
+
+ PopulationEntry newEntry;
+ if (resetModelsOption.isSet()) {
+ Classifier model = createModelWithParams(newParams);
+ newEntry = new PopulationEntry(model, newEvaluator(), newParams);
+ } else {
+ Classifier model = population[0].model.copy();
+ LearnerConfigurator.reseedCopy(model, this.classifierRandom.nextInt());
+ model = applyParamsToModel(model, newParams);
+ newEntry = new PopulationEntry(model, newEvaluator(), newParams);
+ }
+ newPopulation[i] = newEntry;
+ }
+
+ population = newPopulation;
+ }
+
+ private void sortPopulation() {
+ int metric = metricOption.getChosenIndex();
+ Arrays.sort(population, (a, b) -> Double.compare(b.getMetric(metric), a.getMetric(metric)));
+ }
+
+ // DE/best/1 mutation: v = best + mutationFactor * (r1 - r2)
+ private ArrayList deMutationBest1(int targetIndex) {
+ List pool = new ArrayList<>();
+ for (int i = 1; i < population.length; i++) {
+ if (i != targetIndex) pool.add(i);
+ }
+ Collections.shuffle(pool, classifierRandom);
+ int r1Idx = pool.get(0);
+ int r2Idx = pool.get(1);
+
+ ArrayList best = population[0].params;
+ ArrayList r1 = population[r1Idx].params;
+ ArrayList r2 = population[r2Idx].params;
+
+ ArrayList trial = new ArrayList<>();
+ for (int i = 0; i < best.size(); i++) {
+ Parameter pb = best.get(i);
+ Parameter p1 = r1.get(i);
+ Parameter p2 = r2.get(i);
+ switch (pb.type) {
+ case Parameter.TYPE_INT: {
+ int[] range = ((IntParameter) pb).range;
+ double mutated = ((IntParameter) pb).value + mutationFactor * (((IntParameter) p1).value - ((IntParameter) p2).value);
+ int val = clampInt((int) Math.round(mutated), range[0], range[1]);
+ trial.add(new IntParameter(pb.name, val, range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_DOUBLE: {
+ double[] range = ((DoubleParameter) pb).range;
+ double mutated = ((DoubleParameter) pb).value + mutationFactor * (((DoubleParameter) p1).value - ((DoubleParameter) p2).value);
+ double val = clampDouble(mutated, range[0], range[1]);
+ trial.add(new DoubleParameter(pb.name, val, range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_CATEGORICAL: {
+ CategoricalParameter cp = (CategoricalParameter) pb;
+ int active = classifierRandom.nextInt(cp.values.length);
+ trial.add(new CategoricalParameter(pb.name, cp.values.clone(), active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return trial;
+ }
+
+ private ArrayList crossover(ArrayList target, ArrayList trial) {
+ ArrayList result = new ArrayList<>();
+ for (int i = 0; i < target.size(); i++) {
+ Parameter pt = target.get(i);
+ Parameter pv = trial.get(i);
+ boolean useTrial = classifierRandom.nextDouble() < crossoverRate;
+ switch (pt.type) {
+ case Parameter.TYPE_INT: {
+ IntParameter ip = (IntParameter) (useTrial ? pv : pt);
+ result.add(new IntParameter(pt.name, ip.value, ((IntParameter) pt).range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_DOUBLE: {
+ DoubleParameter dp = (DoubleParameter) (useTrial ? pv : pt);
+ result.add(new DoubleParameter(pt.name, dp.value, ((DoubleParameter) pt).range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_CATEGORICAL: {
+ CategoricalParameter ct = (CategoricalParameter) pt;
+ CategoricalParameter cv = (CategoricalParameter) pv;
+ int active = useTrial ? cv.active : ct.active;
+ result.add(new CategoricalParameter(pt.name, ct.values.clone(), active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return result;
+ }
+
+ private boolean checkConvergence() {
+ if (oldBestParams == null) return false;
+ ArrayList current = population[0].params;
+ double distSq = 0;
+ for (int i = 0; i < oldBestParams.size(); i++) {
+ Parameter op = oldBestParams.get(i);
+ Parameter cp = current.get(i);
+ if (op.type == 0) {
+ double diff = ((IntParameter) op).value - ((IntParameter) cp).value;
+ distSq += diff * diff;
+ } else if (op.type == 1) {
+ double diff = ((DoubleParameter) op).value - ((DoubleParameter) cp).value;
+ distSq += diff * diff;
+ }
+ }
+ double threshold = convergenceSphereOption.getValue();
+ return distSq < threshold * threshold;
+ }
+
+ // ========== HELPERS ==========
+
+ private ArrayList cloneParams(ArrayList source) {
+ ArrayList copy = new ArrayList<>();
+ for (Parameter p : source) {
+ switch (p.type) {
+ case Parameter.TYPE_INT: {
+ IntParameter ip = (IntParameter) p;
+ copy.add(new IntParameter(ip.name, ip.value, ip.range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_DOUBLE: {
+ DoubleParameter dp = (DoubleParameter) p;
+ copy.add(new DoubleParameter(dp.name, dp.value, dp.range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_CATEGORICAL: {
+ CategoricalParameter cp = (CategoricalParameter) p;
+ copy.add(new CategoricalParameter(cp.name, cp.values.clone(), cp.active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return copy;
+ }
+
+ private int clampInt(int val, int min, int max) {
+ return Math.max(min, Math.min(max, val));
+ }
+
+ private double clampDouble(double val, double min, double max) {
+ return Math.max(min, Math.min(max, val));
+ }
+
+
+
+ // ========== MOA INTERFACE ==========
+
+ @Override
+ protected Measurement[] getModelMeasurementsImpl() {
+ ArrayList measurements = new ArrayList<>();
+ measurements.add(new Measurement("driftsDetected", driftsDetected));
+ for (Measurement m : population[0].model.getModelMeasurements())
+ measurements.add(m);
+ measurements.add(new Measurement("mutationFactor", mutationFactor));
+ measurements.add(new Measurement("crossoverRate", crossoverRate));
+ for (Parameter p : population[0].params) {
+ switch (p.type) {
+ case Parameter.TYPE_INT:
+ measurements.add(new Measurement(p.name, ((IntParameter) p).value));
+ break;
+ case Parameter.TYPE_DOUBLE:
+ measurements.add(new Measurement(p.name, ((DoubleParameter) p).value));
+ break;
+ case Parameter.TYPE_CATEGORICAL:
+ measurements.add(new Measurement(p.name, ((CategoricalParameter) p).active));
+ break;
+ }
+ }
+ return measurements.toArray(new Measurement[0]);
+ }
+
+ @Override
+ public void getModelDescription(StringBuilder out, int indent) {
+ }
+
+ @Override
+ public long measureByteSize() {
+ long size = SizeOf.sizeOf(this);
+ for (PopulationEntry entry : population)
+ size += entry.model.measureByteSize();
+ return size;
+ }
+
+ @Override
+ public double getCandidateScore(int i) {
+ return population[i].getMetric(metricOption.getChosenIndex());
+ }
+
+ @Override
+ public double getClassifierScore() {
+ return population[0].getMetric(metricOption.getChosenIndex());
+ }
+
+ @Override
+ public int getNumberOfCandidates() { return populationSizeOption.getValue(); }
+
+ @Override
+ public long getStatesEvaluatedCount() { return instanceCount / periodicityOption.getValue(); }
+
+ @Override
+ public int getEvaluationInstancesCount() { return evaluationInstances; }
+
+ @Override
+ public int getPeriodicity() { return periodicityOption.getValue(); }
+
+ @Override
+ public Classifier getMainClassifier() { return population[0].model; }
+
+ @Override
+ public ArrayList getReferenceParameters() {
+ return (population != null && population.length > 0) ? population[0].params : null;
+ }
+
+ @Override
+ public String getConfigurationFile() { return this.configurationFileOption.getValue();}
+
+
+ @Override
+ public ArrayList> getCandidateParameters() {
+ ArrayList> list = new ArrayList<>();
+ if (population != null) {
+ for (PopulationEntry e : population) {
+ list.add(e.params);
+ }
+ }
+ return list;
+ }
+
+
+ /**
+ * Feeds the incumbent's prediction error to the change detector and, when a
+ * drift is signalled, reinitializes the whole search from the search space.
+ * The signal is the absolute error |y - y_pred|.
+ */
+ private void checkDrift(Instance inst, double[] incumbentVotes) {
+ double predicted = incumbentVotes.length > 0 ? incumbentVotes[0] : 0.0;
+ driftDetector.input(Math.abs(predicted - inst.classValue()));
+ if (driftDetector.getChange()) {
+ driftsDetected++;
+ if (verboseOption.isSet())
+ System.out.println("MESSPT: Drift detected at instance " + instanceCount
+ + ", reinitializing");
+ resetLearningImpl();
+ }
+ }
+
+ /**
+ * Inner class to assist with the multi-thread execution.
+ */
+ protected class TrainingRunnable implements Runnable, Callable {
+ final private Classifier learner;
+ final private Instance instance;
+
+ public TrainingRunnable(Classifier learner, Instance instance) {
+ this.learner = learner;
+ this.instance = instance;
+ }
+
+ @Override
+ public void run() {
+ this.learner.trainOnInstance(this.instance);
+ }
+
+ @Override
+ public Integer call() {
+ run();
+ return 0;
+ }
+ }
+}
diff --git a/moa/src/main/java/moa/classifiers/AutoML/MetricUtils.java b/moa/src/main/java/moa/classifiers/AutoML/MetricUtils.java
new file mode 100644
index 000000000..c7276f022
--- /dev/null
+++ b/moa/src/main/java/moa/classifiers/AutoML/MetricUtils.java
@@ -0,0 +1,197 @@
+/*
+ * MetricUtils.java
+ * Copyright (C) 2026 University of Waikato, Hamilton, New Zealand
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package moa.classifiers.AutoML;
+
+import moa.core.Measurement;
+import moa.evaluation.BasicClassificationPerformanceEvaluator;
+
+/**
+ * Shared metric selection for the AutoML methods. Centralises the list of
+ * metrics exposed through each method's {@code metricOption}, read out of a
+ * {@link BasicClassificationPerformanceEvaluator}'s measurements by name so it
+ * is independent of the number of classes.
+ *
+ * F1, precision and recall are aggregated here rather than taken from the
+ * evaluator's own aggregate measurements, for two reasons. First, MOA's
+ * {@code getF1Statistic()} returns the F1 of the averaged precision and
+ * recall, which is not the macro F1 and is not what a search should rank
+ * candidates by. Second, a class absent from an evaluation window leaves its
+ * per-class estimator at {@code 0/0}, so MOA's aggregates collapse to
+ * {@code NaN} and every candidate ties - fatal for a search that ranks by that
+ * score. Both are handled here by averaging per-class values under an explicit
+ * NaN policy, which keeps the correction inside this package instead of
+ * changing an evaluator the rest of MOA depends on.
+ *
+ *
Methods using these metrics must switch on the per-class outputs of their
+ * evaluators, which {@link #configure} does.
+ *
+ * @author Daniel Nowak Assis (daniel dot nowak-assis at lip6 dot fr)
+ */
+public final class MetricUtils {
+
+ public static final String[] METRIC_NAMES = {
+ "Accuracy", "Kappa", "KappaTemporal", "KappaM", "F1", "Precision", "Recall"
+ };
+
+ public static final String[] METRIC_DESCRIPTIONS = {
+ "Percentage of correctly classified instances",
+ "Kappa statistic (percent)",
+ "Kappa Temporal statistic (percent)",
+ "Kappa M statistic (percent)",
+ "F1 score, macro averaged over classes (percent)",
+ "Precision, macro averaged over observed classes (percent)",
+ "Recall, macro averaged over observed classes (percent)"
+ };
+
+ public static final int ACCURACY = 0;
+ public static final int KAPPA = 1;
+ public static final int KAPPA_TEMPORAL = 2;
+ public static final int KAPPA_M = 3;
+ public static final int F1 = 4;
+ public static final int PRECISION = 5;
+ public static final int RECALL = 6;
+
+ private static final String[] AGGREGATE_NAMES = {
+ "classifications correct (percent)",
+ "Kappa Statistic (percent)",
+ "Kappa Temporal Statistic (percent)",
+ "Kappa M Statistic (percent)",
+ "F1 Score (percent)",
+ "Precision (percent)",
+ "Recall (percent)"
+ };
+
+ private static final String F1_PER_CLASS = "F1 Score for class ";
+ private static final String PRECISION_PER_CLASS = "Precision for class ";
+ private static final String RECALL_PER_CLASS = "Recall for class ";
+
+ public static final String[] REGRESSION_METRIC_NAMES = {
+ "R2", "AdjustedR2", "MAE", "RMSE", "RelativeMAE", "RelativeRMSE"
+ };
+
+ public static final String[] REGRESSION_METRIC_DESCRIPTIONS = {
+ "Coefficient of determination",
+ "Adjusted coefficient of determination",
+ "Mean absolute error",
+ "Root mean squared error",
+ "Relative mean absolute error",
+ "Relative root mean squared error"
+ };
+
+ private static final String[] REGRESSION_MEASUREMENT_NAMES = {
+ "coefficient of determination",
+ "adjusted coefficient of determination",
+ "mean absolute error",
+ "root mean squared error",
+ "relative mean absolute error",
+ "relative root mean squared error"
+ };
+
+ /** Whether a larger value of the metric at the same index means a better model. */
+ private static final boolean[] REGRESSION_HIGHER_IS_BETTER = {
+ true, true, false, false, false, false
+ };
+
+ private MetricUtils() {}
+
+ /**
+ * Switches on every output {@link #getScore} needs. Call this on each
+ * evaluator an AutoML method creates, in place of setting
+ * {@code precisionRecallOutputOption} alone.
+ */
+ public static void configure(BasicClassificationPerformanceEvaluator evaluator) {
+ evaluator.precisionRecallOutputOption.setValue(true);
+ evaluator.f1PerClassOption.setValue(true);
+ evaluator.precisionPerClassOption.setValue(true);
+ evaluator.recallPerClassOption.setValue(true);
+ // A freshly constructed evaluator allocates its estimators only in
+ // reset(), so getPerformanceMeasurements() would throw on one that has
+ // not scored an instance yet. That happens whenever the search state is
+ // read at an evaluation window boundary, just after candidates were
+ // respawned with new evaluators.
+ evaluator.reset();
+ }
+
+ public static double getScore(Measurement[] measurements, int metricChoice) {
+ if (measurements == null || metricChoice < 0 || metricChoice >= AGGREGATE_NAMES.length) {
+ return Double.NEGATIVE_INFINITY;
+ }
+ switch (metricChoice) {
+ case F1:
+ // An undefined class scores zero, so the average stays over all
+ // classes and a candidate that ignores a class is penalised.
+ return macroAverage(measurements, F1_PER_CLASS, AGGREGATE_NAMES[F1], true);
+ case PRECISION:
+ return macroAverage(measurements, PRECISION_PER_CLASS, AGGREGATE_NAMES[PRECISION], false);
+ case RECALL:
+ return macroAverage(measurements, RECALL_PER_CLASS, AGGREGATE_NAMES[RECALL], false);
+ default:
+ return lookup(measurements, AGGREGATE_NAMES[metricChoice]);
+ }
+ }
+
+ /**
+ * Averages the per-class entries whose name starts with {@code prefix}.
+ * When {@code countUndefined} is set, classes with no observations count as
+ * zero; otherwise they are left out of the average entirely. Falls back to
+ * {@code aggregateName} if per-class outputs are switched off.
+ */
+ private static double macroAverage(Measurement[] measurements, String prefix,
+ String aggregateName, boolean countUndefined) {
+ double total = 0.0;
+ int classes = 0;
+ int defined = 0;
+ for (Measurement measurement : measurements) {
+ if (!measurement.getName().startsWith(prefix)) continue;
+ classes++;
+ double value = measurement.getValue();
+ if (Double.isNaN(value)) continue;
+ total += value;
+ defined++;
+ }
+ if (classes == 0) return lookup(measurements, aggregateName);
+ if (countUndefined) return total / classes;
+ return defined == 0 ? 0.0 : total / defined;
+ }
+
+ /**
+ * Regression counterpart of {@link #getScore}, always oriented so that
+ * larger is better: error metrics are negated, so a search can rank
+ * candidates with a plain {@code >} whichever metric is selected.
+ *
+ *
Measurements are looked up by name rather than by position, and the
+ * same metric is used for candidates and for the incumbent. Comparing, say,
+ * a candidate's adjusted R² against the incumbent's plain R² would make
+ * swaps depend on the number of attributes rather than on model quality.
+ */
+ public static double getRegressionScore(Measurement[] measurements, int metricChoice) {
+ if (measurements == null || metricChoice < 0 || metricChoice >= REGRESSION_MEASUREMENT_NAMES.length) {
+ return Double.NEGATIVE_INFINITY;
+ }
+ double value = lookup(measurements, REGRESSION_MEASUREMENT_NAMES[metricChoice]);
+ if (Double.isNaN(value)) return Double.NEGATIVE_INFINITY;
+ return REGRESSION_HIGHER_IS_BETTER[metricChoice] ? value : -value;
+ }
+
+ private static double lookup(Measurement[] measurements, String name) {
+ for (Measurement measurement : measurements) {
+ if (name.equals(measurement.getName())) return measurement.getValue();
+ }
+ return Double.NEGATIVE_INFINITY;
+ }
+}
diff --git a/moa/src/main/java/moa/classifiers/AutoML/Parameters/CategoricalParameter.java b/moa/src/main/java/moa/classifiers/AutoML/Parameters/CategoricalParameter.java
new file mode 100644
index 000000000..bae379689
--- /dev/null
+++ b/moa/src/main/java/moa/classifiers/AutoML/Parameters/CategoricalParameter.java
@@ -0,0 +1,55 @@
+/*
+ * CategoricalParameter.java
+ * Copyright (C) 2026 University of Waikato, Hamilton, New Zealand
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package moa.classifiers.AutoML.Parameters;
+
+import java.io.Serializable;
+import java.util.Random;
+
+/**
+ * A categorical hyperparameter: one of {@link #values} is selected by the
+ * {@link #active} index, which is what the search actually moves.
+ *
+ * @author Daniel Nowak Assis (daniel dot nowak-assis at lip6 dot fr)
+ */
+public class CategoricalParameter extends Parameter implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ public String[] values;
+ public int active;
+ public Random random;
+
+ public CategoricalParameter(String name, String[] values, int active, Random random) {
+ this.name = name;
+ this.values = values;
+ this.active = active;
+ this.type = TYPE_CATEGORICAL;
+ this.random = random;
+ }
+
+ @Override
+ public void changeParameter() {
+ this.active = this.random.nextInt(this.values.length);
+ }
+
+ @Override
+ public String toString() {
+ return "CategoricalParameter [name=" + this.name
+ + ", active=" + this.values[this.active] + "]";
+ }
+}
diff --git a/moa/src/main/java/moa/classifiers/AutoML/Parameters/DoubleParameter.java b/moa/src/main/java/moa/classifiers/AutoML/Parameters/DoubleParameter.java
new file mode 100644
index 000000000..14d7756d1
--- /dev/null
+++ b/moa/src/main/java/moa/classifiers/AutoML/Parameters/DoubleParameter.java
@@ -0,0 +1,56 @@
+/*
+ * DoubleParameter.java
+ * Copyright (C) 2026 University of Waikato, Hamilton, New Zealand
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package moa.classifiers.AutoML.Parameters;
+
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.Random;
+
+/**
+ * A real-valued hyperparameter, uniformly drawn from an inclusive range.
+ *
+ * @author Daniel Nowak Assis (daniel dot nowak-assis at lip6 dot fr)
+ */
+public class DoubleParameter extends Parameter implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ public double value;
+ public double[] range;
+ public Random random;
+
+ public DoubleParameter(String name, double value, double[] range, Random random) {
+ this.name = name;
+ this.value = value;
+ this.range = range;
+ this.type = TYPE_DOUBLE;
+ this.random = random;
+ }
+
+ @Override
+ public void changeParameter() {
+ this.value = this.range[0] + ((this.range[1] - this.range[0]) * this.random.nextDouble());
+ this.value = Math.min(this.range[1], Math.max(this.range[0], this.value));
+ }
+
+ @Override
+ public String toString() {
+ return "DoubleParameter [name=" + this.name + ", value=" + this.value
+ + ", range=" + Arrays.toString(this.range) + "]";
+ }
+}
diff --git a/moa/src/main/java/moa/classifiers/AutoML/Parameters/IntParameter.java b/moa/src/main/java/moa/classifiers/AutoML/Parameters/IntParameter.java
new file mode 100644
index 000000000..00299815b
--- /dev/null
+++ b/moa/src/main/java/moa/classifiers/AutoML/Parameters/IntParameter.java
@@ -0,0 +1,56 @@
+/*
+ * IntParameter.java
+ * Copyright (C) 2026 University of Waikato, Hamilton, New Zealand
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package moa.classifiers.AutoML.Parameters;
+
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.Random;
+
+/**
+ * An integer hyperparameter, uniformly drawn from an inclusive range.
+ *
+ * @author Daniel Nowak Assis (daniel dot nowak-assis at lip6 dot fr)
+ */
+public class IntParameter extends Parameter implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ public int value;
+ public int[] range;
+ public Random random;
+
+ public IntParameter(String name, int value, int[] range, Random random) {
+ this.name = name;
+ this.value = value;
+ this.range = range;
+ this.type = TYPE_INT;
+ this.random = random;
+ }
+
+ @Override
+ public void changeParameter() {
+ this.value = this.random.nextInt((this.range[1] - this.range[0] + 1)) + this.range[0];
+ this.value = Math.min(this.range[1], Math.max(this.range[0], this.value));
+ }
+
+ @Override
+ public String toString() {
+ return "IntParameter [name=" + this.name + ", value=" + this.value
+ + ", range=" + Arrays.toString(this.range) + "]";
+ }
+}
diff --git a/moa/src/main/java/moa/classifiers/AutoML/Parameters/Parameter.java b/moa/src/main/java/moa/classifiers/AutoML/Parameters/Parameter.java
new file mode 100644
index 000000000..cd92a3880
--- /dev/null
+++ b/moa/src/main/java/moa/classifiers/AutoML/Parameters/Parameter.java
@@ -0,0 +1,54 @@
+/*
+ * Parameter.java
+ * Copyright (C) 2026 University of Waikato, Hamilton, New Zealand
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package moa.classifiers.AutoML.Parameters;
+
+import java.io.Serializable;
+
+/**
+ * One hyperparameter carrying a concrete value for one candidate. This is the
+ * mutable half of a hyperparameter: its immutable description, as read from the
+ * search space JSON, lives in {@link moa.classifiers.AutoML.space.ParameterSpec}.
+ *
+ *
{@link #name} is the option path of the hyperparameter, e.g.
+ * {@code "gracePeriod"} or {@code "treeLearner/gracePeriod"}, matching the
+ * {@code "parameter"} entry of the search space JSON.
+ *
+ * @author Daniel Nowak Assis (daniel dot nowak-assis at lip6 dot fr)
+ */
+public abstract class Parameter implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** Type tag of {@link IntParameter}. */
+ public static final int TYPE_INT = 0;
+
+ /** Type tag of {@link DoubleParameter}. */
+ public static final int TYPE_DOUBLE = 1;
+
+ /** Type tag of {@link CategoricalParameter}. */
+ public static final int TYPE_CATEGORICAL = 2;
+
+ /** One of {@link #TYPE_INT}, {@link #TYPE_DOUBLE}, {@link #TYPE_CATEGORICAL}. */
+ public int type;
+
+ /** Option path of this hyperparameter. */
+ public String name;
+
+ /** Draws a new value for this hyperparameter from its own range. */
+ public abstract void changeParameter();
+}
diff --git a/moa/src/main/java/moa/classifiers/AutoML/RandomSearchClassifier.java b/moa/src/main/java/moa/classifiers/AutoML/RandomSearchClassifier.java
new file mode 100644
index 000000000..0ab799f75
--- /dev/null
+++ b/moa/src/main/java/moa/classifiers/AutoML/RandomSearchClassifier.java
@@ -0,0 +1,636 @@
+/*
+ * RandomSearchClassifier.java
+ * Copyright (C) 2026 University of Waikato, Hamilton, New Zealand
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package moa.classifiers.AutoML;
+
+import com.github.javacliparser.FileOption;
+import com.github.javacliparser.FlagOption;
+import com.github.javacliparser.IntOption;
+import com.github.javacliparser.MultiChoiceOption;
+import com.github.javacliparser.StringOption;
+import com.yahoo.labs.samoa.instances.Instance;
+import moa.capabilities.CapabilitiesHandler;
+import moa.classifiers.AbstractClassifier;
+import moa.classifiers.AutoML.Parameters.CategoricalParameter;
+import moa.classifiers.AutoML.Parameters.DoubleParameter;
+import moa.classifiers.AutoML.Parameters.IntParameter;
+import moa.classifiers.AutoML.Parameters.Parameter;
+import moa.classifiers.AutoML.space.ConfigurationSpace;
+import moa.classifiers.AutoML.space.LearnerConfigurator;
+import moa.classifiers.AutoML.space.ParameterSpec;
+import moa.classifiers.Classifier;
+import moa.classifiers.core.driftdetection.ChangeDetector;
+import moa.classifiers.MultiClassClassifier;
+import moa.core.InstanceExample;
+import moa.core.Measurement;
+import moa.core.SizeOf;
+import moa.options.ClassOption;
+import moa.evaluation.BasicClassificationPerformanceEvaluator;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Random;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Random search over a streaming hyperparameter space: a pool of candidates
+ * carries randomly drawn configurations, and whenever a candidate beats the
+ * incumbent over an evaluation window it takes its place.
+ *
+ *
Candidates are configured through {@link LearnerConfigurator}, i.e. by
+ * writing MOA {@link com.github.javacliparser.Option}s, so any MOA classifier
+ * can be tuned as shipped.
+ *
+ * @author Daniel Nowak Assis (daniel dot nowak-assis at lip6 dot fr)
+ */
+public class RandomSearchClassifier extends AbstractClassifier implements MultiClassClassifier,
+ CapabilitiesHandler, Serializable, HPOMethod {
+
+ private static final long serialVersionUID = 1L;
+
+ public FileOption configurationFileOption = new FileOption("configurationFile", 'f',
+ "Search space in JSON format.", null, ".json", false);
+
+ public StringOption searchSpaceOption = new StringOption("searchSpace", 's',
+ "Search space as inline JSON. Takes precedence over configurationFile when set,"
+ + " so that a caller holding the space in memory need not write a file.", "");
+
+ public IntOption periodicityOption = new IntOption("periodicity", 'g',
+ "Number of instances between candidate evaluations.", 1000, 1, Integer.MAX_VALUE);
+
+ public FlagOption randomInitialParametersOption = new FlagOption("randomInitialParameters", 'R',
+ "Draw the initial configuration at random instead of using the values declared in the search space.");
+
+ public IntOption numberOfCandidatesOption = new IntOption("numberOfCandidates", 'n',
+ "Number of candidate models in the pool.", 10, 1, Integer.MAX_VALUE);
+
+ public MultiChoiceOption metricOption = new MultiChoiceOption("metric", 'm',
+ "Metric to optimize the model.", MetricUtils.METRIC_NAMES, MetricUtils.METRIC_DESCRIPTIONS, 4);
+
+ public FlagOption resetLearningOption = new FlagOption("resetLearning", 'L',
+ "Reset candidate learning instead of copying internal model state from the best classifier.");
+
+ public FlagOption driftDetectionOption = new FlagOption("driftDetection", 'd',
+ "Enable drift detection on the incumbent's prediction error.");
+
+ public ClassOption driftDetectorOption = new ClassOption("driftDetector", 'D',
+ "Change detector to use when drift detection is enabled; on a detected"
+ + " drift the whole search is reinitialized from the search space.",
+ ChangeDetector.class, "ADWINChangeDetector");
+
+ public IntOption numberOfJobsOption = new IntOption("numberOfJobs", 'j',
+ "Total number of concurrent jobs used for processing (-1 = as much as possible, 0 = do not use multithreading)",
+ 1, -1, Integer.MAX_VALUE);
+
+ protected static final int SINGLE_THREAD = 0;
+
+ public long instanceCount;
+
+ public long statesEvaluated;
+
+ public Classifier classifier;
+
+ protected Classifier[] candidates;
+
+ ArrayList classifierParameters;
+
+ ArrayList> candidatesParameters;
+
+ int numericalParameters;
+
+ protected BasicClassificationPerformanceEvaluator evaluatorClassifier;
+
+ protected BasicClassificationPerformanceEvaluator[] evaluatorCandidates;
+
+ int evaluationInstances = 0;
+
+ /** Detector on the incumbent's error; {@code null} unless drift detection is on. */
+ protected ChangeDetector driftDetector;
+
+ /** Cumulative number of drifts signalled over the run; not reset by a restart. */
+ protected long driftsDetected;
+
+ protected boolean[] optimizeMask;
+
+ /** Search space and the boundary through which every learner is configured. */
+ protected LearnerConfigurator configurator;
+
+ /**
+ * Parameter list most recently written onto each candidate. Needed when a
+ * candidate has to be rebuilt rather than reconfigured in place.
+ */
+ protected ArrayList[] appliedParameters;
+
+ /**
+ * Candidates actually trained and evaluated, i.e. the active prefix of the
+ * pool. The pool itself is always allocated at {@code numberOfCandidatesOption}
+ * so the budget can move without reallocating anything.
+ */
+ protected int activeCandidates;
+
+ /** Budget requested by an external controller, applied at the next window boundary. */
+ protected int pendingActiveCandidates = -1;
+
+ /**
+ * Pool training the incumbent and the candidates concurrently; {@code null}
+ * when running single-threaded. Transient because an executor cannot be
+ * serialized, so it is (re)created on demand by {@link #initExecutor()}.
+ */
+ protected transient ExecutorService executor;
+
+ @Override
+ public void setOptimizableParameters(boolean[] mask) { this.optimizeMask = mask; }
+
+ @Override
+ public int getActiveCandidates() { return this.activeCandidates; }
+
+ @Override
+ public int setActiveCandidates(int n) {
+ this.pendingActiveCandidates = Math.max(1, Math.min(n, this.numberOfCandidatesOption.getValue()));
+ return this.pendingActiveCandidates;
+ }
+
+ /**
+ * Pin every frozen hyperparameter (optimizeMask[k] == false) of every
+ * candidate to the incumbent value, and re-apply it to the candidate model
+ * so the frozen value is actually used.
+ */
+ private void applyFreezeMask() {
+ if (this.optimizeMask == null) return;
+ for (int i = 0; i < this.activeCandidates; i++) {
+ ArrayList params = this.candidatesParameters.get(i);
+ HPOMethod.freezeToIncumbent(this.optimizeMask, params, this.classifierParameters);
+ for (int k = 0; k < params.size() && k < this.optimizeMask.length; k++) {
+ if (this.optimizeMask[k]) continue;
+ this.configurator.applyLive(this.candidates[i], params.get(k));
+ }
+ }
+ }
+
+ @Override
+ public boolean isRandomizable() {
+ return true;
+ }
+
+ @Override
+ public double[] getVotesForInstance(Instance inst) {
+ //Main classifiers votes
+ return this.classifier.getVotesForInstance(inst);
+ }
+
+ /**
+ * Creates the training pool on first use, and again after deserialization.
+ * A pool larger than the batch trained per instance would leave threads idle,
+ * so the requested job count is capped at the incumbent plus the whole pool.
+ */
+ protected void initExecutor() {
+ if (this.executor != null) return;
+ int numberOfJobs = this.numberOfJobsOption.getValue() == -1
+ ? Runtime.getRuntime().availableProcessors()
+ : this.numberOfJobsOption.getValue();
+ numberOfJobs = Math.min(numberOfJobs, this.numberOfCandidatesOption.getValue() + 1);
+ // SINGLE_THREAD and requesting a single thread are equivalent: training
+ // then happens in-place and this.executor stays null.
+ if (numberOfJobs != SINGLE_THREAD && numberOfJobs != 1) {
+ // Daemon threads: a live pool must not keep the JVM alive after the
+ // task that ran this learner has finished.
+ this.executor = Executors.newFixedThreadPool(numberOfJobs, runnable -> {
+ Thread thread = new Thread(runnable);
+ thread.setDaemon(true);
+ return thread;
+ });
+ }
+ }
+
+ @Override
+ public void cleanThreads() {
+ if (this.executor != null) {
+ this.executor.shutdownNow();
+ this.executor = null;
+ }
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public void resetLearningImpl() {
+ cleanThreads(); // shut down any pool from a previous reset before creating a new one
+ driftDetector = driftDetectionOption.isSet()
+ ? ((ChangeDetector) getPreparedClassOption(driftDetectorOption)).copy()
+ : null;
+ this.classifierParameters = new ArrayList();
+ this.candidatesParameters = new ArrayList<>();
+ for (int i = 0; i < this.numberOfCandidatesOption.getValue(); i++) {
+ this.candidatesParameters.add(new ArrayList());
+ }
+
+
+ this.evaluatorClassifier = new BasicClassificationPerformanceEvaluator();
+ MetricUtils.configure(this.evaluatorClassifier);
+ this.evaluatorCandidates = new BasicClassificationPerformanceEvaluator[this.numberOfCandidatesOption.getValue()];
+ for (int i = 0; i < this.numberOfCandidatesOption.getValue(); i++) {
+ this.evaluatorCandidates[i] = new BasicClassificationPerformanceEvaluator();
+ MetricUtils.configure(this.evaluatorCandidates[i]);
+ }
+
+ this.appliedParameters = new ArrayList[this.numberOfCandidatesOption.getValue()];
+
+ this.setConfigurations();
+ this.instanceCount = 0;
+ this.statesEvaluated = 0;
+ this.activeCandidates = this.numberOfCandidatesOption.getValue();
+ this.pendingActiveCandidates = -1;
+ }
+
+ /**
+ * Reads the search space, creates the incumbent and the candidate pool, and
+ * seeds the per-parameter random generators.
+ */
+ public void setConfigurations() {
+ try {
+ this.numericalParameters = 0;
+
+ ConfigurationSpace space = ConfigurationSpace.resolve(this.configurationFileOption.getValue(), this.searchSpaceOption.getValue());
+ this.configurator = new LearnerConfigurator(space);
+ this.configurator.validate();
+
+ this.classifier = this.configurator.newLearner(this.classifierRandom.nextInt());
+
+ this.candidates = new Classifier[this.numberOfCandidatesOption.getValue()];
+ for (int i = 0; i < this.numberOfCandidatesOption.getValue(); i++) {
+ // Built one by one rather than copied from a single base, so that
+ // each candidate carries its own random seed.
+ this.candidates[i] = this.configurator.newLearner(this.classifierRandom.nextInt());
+ }
+
+ for (ParameterSpec spec : space.parameters) {
+ switch (spec.type) {
+ case ParameterSpec.TYPE_CATEGORICAL: {
+ int active;
+ if (!this.randomInitialParametersOption.isSet()) {
+ active = spec.active;
+ } else {
+ active = this.classifierRandom.nextInt(spec.values.length);
+ }
+ this.classifierParameters.add(new CategoricalParameter(spec.name, spec.values,
+ active, new Random(this.classifierRandom.nextLong())));
+ for (int i = 0; i < this.numberOfCandidatesOption.getValue(); i++) {
+ this.candidatesParameters.get(i).add(new CategoricalParameter(spec.name,
+ spec.values, active, new Random(this.classifierRandom.nextLong())));
+ }
+ break;
+ }
+ case ParameterSpec.TYPE_INT: {
+ int[] range = new int[]{(int) spec.range[0], (int) spec.range[1]};
+ int value;
+ if (!this.randomInitialParametersOption.isSet()) {
+ value = (int) spec.value;
+ } else {
+ value = this.classifierRandom.nextInt(range[1] + 1 - range[0]) + range[0];
+ }
+ this.classifierParameters.add(new IntParameter(spec.name, value, range,
+ new Random(this.classifierRandom.nextLong())));
+ for (int i = 0; i < this.numberOfCandidatesOption.getValue(); i++) {
+ this.candidatesParameters.get(i).add(new IntParameter(spec.name, value, range,
+ new Random(this.classifierRandom.nextLong())));
+ }
+ this.numericalParameters++;
+ break;
+ }
+ case ParameterSpec.TYPE_DOUBLE: {
+ double[] range = new double[]{spec.range[0], spec.range[1]};
+ double value;
+ if (!this.randomInitialParametersOption.isSet()) {
+ value = spec.value;
+ } else {
+ value = range[0] + (range[1] - range[0]) * this.classifierRandom.nextDouble();
+ }
+ this.classifierParameters.add(new DoubleParameter(spec.name, value, range,
+ new Random(this.classifierRandom.nextLong())));
+ for (int i = 0; i < this.numberOfCandidatesOption.getValue(); i++) {
+ this.candidatesParameters.get(i).add(new DoubleParameter(spec.name, value, range,
+ new Random(this.classifierRandom.nextLong())));
+ }
+ this.numericalParameters++;
+ break;
+ }
+ default:
+ break;
+ }
+ }
+
+ // Put the incumbent on its declared starting configuration.
+ this.classifier = this.configurator.instantiate(this.classifierParameters, this.classifierRandom.nextInt());
+
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not set up " + getClass().getSimpleName()
+ + " from " + ConfigurationSpace.describeSource(this.configurationFileOption.getValue(), this.searchSpaceOption.getValue()) + ": " + e.getMessage(), e);
+ }
+ }
+
+ public void deepCopyList(int bestPerforming) {
+ //Swap parameters from candidates to main classifier
+ for (int i = 0; i < this.classifierParameters.size(); i++) {
+ Parameter parameterS = this.candidatesParameters.get(bestPerforming).get(i);
+ Parameter parameterC = this.classifierParameters.get(i);
+ switch (parameterS.type) {
+ case Parameter.TYPE_INT:
+ ((IntParameter) parameterC).value = ((IntParameter) parameterS).value;
+ ((IntParameter) parameterC).range = ((IntParameter) parameterS).range;
+ break;
+ case Parameter.TYPE_DOUBLE:
+ ((DoubleParameter) parameterC).value = ((DoubleParameter) parameterS).value;
+ ((DoubleParameter) parameterC).range = ((DoubleParameter) parameterS).range;
+ break;
+ case Parameter.TYPE_CATEGORICAL:
+ ((CategoricalParameter) parameterC).values = ((CategoricalParameter) parameterS).values;
+ ((CategoricalParameter) parameterC).active = ((CategoricalParameter) parameterS).active;
+ break;
+ }
+ }
+ }
+
+ public void checkParameterChange() {
+ try {
+ double maxAccuracy = 0;
+ double classifierAcc = 0;
+ int bestPerforming = 0;
+
+ if (this.statesEvaluated != 0) {
+ maxAccuracy = MetricUtils.getScore(this.evaluatorCandidates[0].getPerformanceMeasurements(),
+ this.metricOption.getChosenIndex());
+ for (int i = 1; i < this.activeCandidates; i++) {
+ double currentAcc = MetricUtils.getScore(this.evaluatorCandidates[i].getPerformanceMeasurements(),
+ this.metricOption.getChosenIndex());
+ if (currentAcc > maxAccuracy) {
+ bestPerforming = i;
+ maxAccuracy = currentAcc;
+ }
+ }
+
+ classifierAcc = MetricUtils.getScore(this.evaluatorClassifier.getPerformanceMeasurements(),
+ this.metricOption.getChosenIndex());
+ }
+
+ // Swapping classifiers if candidates acc is greater than main classifier
+ if (maxAccuracy > classifierAcc) {
+ this.swapClassifiers(bestPerforming);
+ }
+
+ // Apply an externally requested budget only here: the window is
+ // scored with the budget it ran under, and the new one takes effect
+ // from the candidates regenerated below.
+ if (this.pendingActiveCandidates > 0) {
+ this.activeCandidates = this.pendingActiveCandidates;
+ this.pendingActiveCandidates = -1;
+ }
+
+ boolean live = this.configurator.canApplyLive();
+
+ if (live) {
+ for (int i = 0; i < this.activeCandidates; i++) {
+ if (resetLearningOption.isSet()) {
+ this.candidates[i].resetLearning();
+ } else {
+ this.candidates[i] = this.classifier.copy();
+ LearnerConfigurator.reseedCopy(this.candidates[i], this.classifierRandom.nextInt());
+ }
+ }
+ }
+
+ // Parameter change of candidates. Candidate i is drawn from, and
+ // configured out of, its own parameter list, so its model and its
+ // bookkeeping agree and deepCopyList records the winner's real
+ // configuration.
+ for (int i = 0; i < this.activeCandidates; i++) {
+ ArrayList listp = this.candidatesParameters.get(i);
+ for (Parameter parameter : listp) {
+ this.changeStateParameter(parameter, i);
+ }
+ this.appliedParameters[i] = listp;
+ }
+
+ if (!live) {
+ // At least one hyperparameter is only read when the learner is
+ // built, so the candidate has to be rebuilt around it.
+ for (int i = 0; i < this.activeCandidates; i++) {
+ this.candidates[i] = this.configurator.instantiate(this.appliedParameters[i], this.classifierRandom.nextInt());
+ }
+ }
+
+ // Freeze the hyperparameters outside the optimized subset to the incumbent.
+ this.applyFreezeMask();
+
+ //reset evaluators
+ this.evaluatorClassifier.reset();
+
+ for (int i = 0; i < this.activeCandidates; i++) {
+ this.evaluatorCandidates[i].reset();
+ }
+
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to update candidates in "
+ + getClass().getSimpleName() + ": " + e.getMessage(), e);
+ }
+ }
+
+ public void swapClassifiers(int bestPerforming) {
+ this.classifier = this.candidates[bestPerforming].copy();
+ this.deepCopyList(bestPerforming);
+ }
+
+ @Override
+ public void changeStateParameter(Parameter parameter, int index) {
+ //Draw a new value and push it into the candidate
+ parameter.changeParameter();
+ if (this.configurator.canApplyLive()) {
+ this.configurator.applyLive(this.candidates[index], parameter);
+ }
+ }
+
+ @Override
+ public void trainOnInstanceImpl(Instance inst) {
+ double[] incumbentVotes = driftDetector != null ? getVotesForInstance(inst) : null;
+ initExecutor();
+ //check for change in parameters
+ this.evaluationInstances++;
+
+ if ((this.statesEvaluated == 0) || this.evaluationInstances >= this.periodicityOption.getValue()) {
+ this.checkParameterChange();
+ this.statesEvaluated++;
+ this.evaluationInstances = 0;
+ }
+
+ //update evaluators (update metrics)
+ InstanceExample example = new InstanceExample(inst);
+ this.evaluatorClassifier.addResult(example, this.classifier.getVotesForInstance(inst));
+ for (int i = 0; i < this.activeCandidates; i++) {
+ this.evaluatorCandidates[i].addResult(example, this.candidates[i].getVotesForInstance(inst));
+ }
+
+ //train classifiers
+ if (this.executor == null) {
+ this.classifier.trainOnInstance(inst);
+ for (int i = 0; i < this.activeCandidates; i++) {
+ this.candidates[i].trainOnInstance(inst);
+ }
+ } else {
+ // The incumbent is an independent model, so it joins the batch as one
+ // more task. Every model owns its state, hence no synchronization here.
+ Collection trainers = new ArrayList();
+ trainers.add(new TrainingRunnable(this.classifier, inst));
+ for (int i = 0; i < this.activeCandidates; i++) {
+ trainers.add(new TrainingRunnable(this.candidates[i], inst));
+ }
+ try {
+ this.executor.invokeAll(trainers);
+ } catch (InterruptedException ex) {
+ throw new RuntimeException("Could not call invokeAll() on training threads.");
+ }
+ }
+
+ this.instanceCount++;
+
+ if (incumbentVotes != null) checkDrift(inst, incumbentVotes);
+ }
+
+ @Override
+ public long measureByteSize() {
+ long candidateSize = 0;
+ for (int i = 0; i < this.activeCandidates; i++) {
+ candidateSize += this.candidates[i].measureByteSize();
+ }
+ return SizeOf.sizeOf(this) + this.classifier.measureByteSize() + candidateSize;
+ }
+
+ @Override
+ protected Measurement[] getModelMeasurementsImpl() {
+
+ for (Classifier c : candidates) {
+ c.getModelMeasurements();
+ }
+
+ ArrayList parameters = new ArrayList<>();
+ parameters.add(new Measurement("driftsDetected", driftsDetected));
+
+ for (Measurement m : this.classifier.getModelMeasurements()) {
+ parameters.add(m);
+ }
+ for (Parameter p : this.classifierParameters) {
+ switch (p.type) {
+ case Parameter.TYPE_INT:
+ parameters.add(new Measurement(p.name, ((IntParameter) p).value));
+ break;
+ case Parameter.TYPE_DOUBLE:
+ parameters.add(new Measurement(p.name, ((DoubleParameter) p).value));
+ break;
+ case Parameter.TYPE_CATEGORICAL:
+ parameters.add(new Measurement(p.name, ((CategoricalParameter) p).active));
+ break;
+ }
+ }
+ Measurement[] measurements = new Measurement[parameters.size()];
+ measurements = parameters.toArray(measurements);
+ return measurements;
+ }
+
+ @Override
+ public void getModelDescription(StringBuilder out, int indent) {
+ }
+
+ @Override
+ public ArrayList getReferenceParameters() { return this.classifierParameters; }
+
+ @Override
+ public ArrayList> getCandidateParameters() { return this.candidatesParameters; }
+
+ @Override
+ public double getCandidateScore(int i) {
+ return MetricUtils.getScore(this.evaluatorCandidates[i].getPerformanceMeasurements(),
+ this.metricOption.getChosenIndex());
+ }
+
+ @Override
+ public double getClassifierScore() {
+ return MetricUtils.getScore(this.evaluatorClassifier.getPerformanceMeasurements(),
+ this.metricOption.getChosenIndex());
+ }
+
+ @Override
+ public int getNumberOfCandidates() { return this.numberOfCandidatesOption.getValue(); }
+
+ @Override
+ public long getStatesEvaluatedCount() { return this.statesEvaluated; }
+
+ @Override
+ public int getEvaluationInstancesCount() { return this.evaluationInstances; }
+
+ @Override
+ public int getPeriodicity() { return this.periodicityOption.getValue(); }
+
+ @Override
+ public Classifier getMainClassifier() { return this.classifier; }
+
+ @Override
+ public String getConfigurationFile() { return this.configurationFileOption.getValue(); }
+
+ private int argmax(double[] arr) {
+ int best = 0;
+ for (int i = 1; i < arr.length; i++)
+ if (arr[i] > arr[best]) best = i;
+ return best;
+ }
+
+ /**
+ * Feeds the incumbent's prediction error to the change detector and, when a
+ * drift is signalled, reinitializes the whole search from the search space.
+ * The signal is the 0/1 misclassification indicator.
+ */
+ private void checkDrift(Instance inst, double[] incumbentVotes) {
+ driftDetector.input(argmax(incumbentVotes) != (int) inst.classValue() ? 1.0 : 0.0);
+ if (driftDetector.getChange()) {
+ driftsDetected++;
+ resetLearningImpl();
+ }
+ }
+
+ /**
+ * Inner class to assist with the multi-thread execution.
+ */
+ protected class TrainingRunnable implements Runnable, Callable {
+ final private Classifier learner;
+ final private Instance instance;
+
+ public TrainingRunnable(Classifier learner, Instance instance) {
+ this.learner = learner;
+ this.instance = instance;
+ }
+
+ @Override
+ public void run() {
+ this.learner.trainOnInstance(this.instance);
+ }
+
+ @Override
+ public Integer call() {
+ run();
+ return 0;
+ }
+ }
+}
diff --git a/moa/src/main/java/moa/classifiers/AutoML/RandomSearchRegressor.java b/moa/src/main/java/moa/classifiers/AutoML/RandomSearchRegressor.java
new file mode 100644
index 000000000..96416573f
--- /dev/null
+++ b/moa/src/main/java/moa/classifiers/AutoML/RandomSearchRegressor.java
@@ -0,0 +1,565 @@
+/*
+ * RandomSearchRegressor.java
+ * Copyright (C) 2026 University of Waikato, Hamilton, New Zealand
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package moa.classifiers.AutoML;
+
+import com.github.javacliparser.FileOption;
+import com.github.javacliparser.FlagOption;
+import com.github.javacliparser.IntOption;
+import com.github.javacliparser.MultiChoiceOption;
+import com.github.javacliparser.StringOption;
+import com.yahoo.labs.samoa.instances.Instance;
+import moa.capabilities.CapabilitiesHandler;
+import moa.classifiers.AbstractClassifier;
+import moa.classifiers.AutoML.Parameters.CategoricalParameter;
+import moa.classifiers.AutoML.Parameters.DoubleParameter;
+import moa.classifiers.AutoML.Parameters.IntParameter;
+import moa.classifiers.AutoML.Parameters.Parameter;
+import moa.classifiers.AutoML.space.ConfigurationSpace;
+import moa.classifiers.AutoML.space.LearnerConfigurator;
+import moa.classifiers.AutoML.space.ParameterSpec;
+import moa.classifiers.Classifier;
+import moa.classifiers.core.driftdetection.ChangeDetector;
+import moa.classifiers.Regressor;
+import moa.core.InstanceExample;
+import moa.core.Measurement;
+import moa.core.SizeOf;
+import moa.options.ClassOption;
+import moa.evaluation.BasicRegressionPerformanceEvaluator;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Random;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Regression counterpart of {@link RandomSearchClassifier}: the same
+ * incumbent-plus-candidate-pool search, scored with a regression metric.
+ *
+ * @author Daniel Nowak Assis (daniel dot nowak-assis at lip6 dot fr)
+ */
+public class RandomSearchRegressor extends AbstractClassifier implements Regressor,
+ CapabilitiesHandler, Serializable, HPOMethod {
+
+ private static final long serialVersionUID = 1L;
+
+ public FileOption configurationFileOption = new FileOption("configurationFile", 'f',
+ "Search space in JSON format.", null, ".json", false);
+
+ public StringOption searchSpaceOption = new StringOption("searchSpace", 's',
+ "Search space as inline JSON. Takes precedence over configurationFile when set,"
+ + " so that a caller holding the space in memory need not write a file.", "");
+
+ public IntOption periodicityOption = new IntOption("periodicity", 'g',
+ "Number of instances between candidate evaluations.", 1000, 1, Integer.MAX_VALUE);
+
+ public FlagOption randomInitialParametersOption = new FlagOption("randomInitialParameters", 'R',
+ "Draw the initial configuration at random instead of using the values declared in the search space.");
+
+ public IntOption numberOfCandidatesOption = new IntOption("numberOfCandidates", 'n',
+ "Number of candidate models in the pool.", 10, 1, Integer.MAX_VALUE);
+
+ public MultiChoiceOption metricOption = new MultiChoiceOption("metric", 'm',
+ "Metric to optimize the model.", MetricUtils.REGRESSION_METRIC_NAMES,
+ MetricUtils.REGRESSION_METRIC_DESCRIPTIONS, 0);
+
+ public FlagOption resetLearningOption = new FlagOption("resetLearning", 'L',
+ "Reset candidate learning instead of copying internal model state from the best classifier.");
+
+ public FlagOption driftDetectionOption = new FlagOption("driftDetection", 'd',
+ "Enable drift detection on the incumbent's prediction error.");
+
+ public ClassOption driftDetectorOption = new ClassOption("driftDetector", 'D',
+ "Change detector to use when drift detection is enabled; on a detected"
+ + " drift the whole search is reinitialized from the search space.",
+ ChangeDetector.class, "ADWINChangeDetector");
+
+ public IntOption numberOfJobsOption = new IntOption("numberOfJobs", 'j',
+ "Total number of concurrent jobs used for processing (-1 = as much as possible, 0 = do not use multithreading)",
+ 1, -1, Integer.MAX_VALUE);
+
+ protected static final int SINGLE_THREAD = 0;
+
+ public long instanceCount;
+
+ public long statesEvaluated;
+
+ public Classifier classifier;
+
+ protected Classifier[] candidates;
+
+ ArrayList classifierParameters;
+
+ ArrayList> candidatesParameters;
+
+ int numericalParameters;
+
+ protected BasicRegressionPerformanceEvaluator evaluatorClassifier;
+
+ protected BasicRegressionPerformanceEvaluator[] evaluatorCandidates;
+
+ int evaluationInstances = 0;
+
+ /** Detector on the incumbent's error; {@code null} unless drift detection is on. */
+ protected ChangeDetector driftDetector;
+
+ /** Cumulative number of drifts signalled over the run; not reset by a restart. */
+ protected long driftsDetected;
+
+ protected boolean[] optimizeMask;
+
+ protected LearnerConfigurator configurator;
+
+ protected ArrayList[] appliedParameters;
+
+ protected int activeCandidates;
+
+ protected int pendingActiveCandidates = -1;
+
+ /**
+ * Pool training the incumbent and the candidates concurrently; {@code null}
+ * when running single-threaded. Transient because an executor cannot be
+ * serialized, so it is (re)created on demand by {@link #initExecutor()}.
+ */
+ protected transient ExecutorService executor;
+
+ @Override
+ public void setOptimizableParameters(boolean[] mask) { this.optimizeMask = mask; }
+
+ @Override
+ public int getActiveCandidates() { return this.activeCandidates; }
+
+ @Override
+ public int setActiveCandidates(int n) {
+ this.pendingActiveCandidates = Math.max(1, Math.min(n, this.numberOfCandidatesOption.getValue()));
+ return this.pendingActiveCandidates;
+ }
+
+ private void applyFreezeMask() {
+ if (this.optimizeMask == null) return;
+ for (int i = 0; i < this.activeCandidates; i++) {
+ ArrayList params = this.candidatesParameters.get(i);
+ HPOMethod.freezeToIncumbent(this.optimizeMask, params, this.classifierParameters);
+ for (int k = 0; k < params.size() && k < this.optimizeMask.length; k++) {
+ if (this.optimizeMask[k]) continue;
+ this.configurator.applyLive(this.candidates[i], params.get(k));
+ }
+ }
+ }
+
+ @Override
+ public boolean isRandomizable() {
+ return true;
+ }
+
+ @Override
+ public double[] getVotesForInstance(Instance inst) {
+ return this.classifier.getVotesForInstance(inst);
+ }
+
+ /**
+ * Creates the training pool on first use, and again after deserialization.
+ * A pool larger than the batch trained per instance would leave threads idle,
+ * so the requested job count is capped at the incumbent plus the whole pool.
+ */
+ protected void initExecutor() {
+ if (this.executor != null) return;
+ int numberOfJobs = this.numberOfJobsOption.getValue() == -1
+ ? Runtime.getRuntime().availableProcessors()
+ : this.numberOfJobsOption.getValue();
+ numberOfJobs = Math.min(numberOfJobs, this.numberOfCandidatesOption.getValue() + 1);
+ // SINGLE_THREAD and requesting a single thread are equivalent: training
+ // then happens in-place and this.executor stays null.
+ if (numberOfJobs != SINGLE_THREAD && numberOfJobs != 1) {
+ // Daemon threads: a live pool must not keep the JVM alive after the
+ // task that ran this learner has finished.
+ this.executor = Executors.newFixedThreadPool(numberOfJobs, runnable -> {
+ Thread thread = new Thread(runnable);
+ thread.setDaemon(true);
+ return thread;
+ });
+ }
+ }
+
+ @Override
+ public void cleanThreads() {
+ if (this.executor != null) {
+ this.executor.shutdownNow();
+ this.executor = null;
+ }
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public void resetLearningImpl() {
+ cleanThreads(); // shut down any pool from a previous reset before creating a new one
+ driftDetector = driftDetectionOption.isSet()
+ ? ((ChangeDetector) getPreparedClassOption(driftDetectorOption)).copy()
+ : null;
+ this.classifierParameters = new ArrayList();
+ this.candidatesParameters = new ArrayList<>();
+ for (int i = 0; i < this.numberOfCandidatesOption.getValue(); i++) {
+ this.candidatesParameters.add(new ArrayList());
+ }
+
+
+ this.evaluatorClassifier = new BasicRegressionPerformanceEvaluator();
+ this.evaluatorCandidates = new BasicRegressionPerformanceEvaluator[this.numberOfCandidatesOption.getValue()];
+ for (int i = 0; i < this.numberOfCandidatesOption.getValue(); i++) {
+ this.evaluatorCandidates[i] = new BasicRegressionPerformanceEvaluator();
+ }
+
+ this.appliedParameters = new ArrayList[this.numberOfCandidatesOption.getValue()];
+
+ this.setConfigurations();
+ this.instanceCount = 0;
+ this.statesEvaluated = 0;
+ this.activeCandidates = this.numberOfCandidatesOption.getValue();
+ this.pendingActiveCandidates = -1;
+ }
+
+ public void setConfigurations() {
+ try {
+ this.numericalParameters = 0;
+
+ ConfigurationSpace space = ConfigurationSpace.resolve(this.configurationFileOption.getValue(), this.searchSpaceOption.getValue());
+ this.configurator = new LearnerConfigurator(space);
+ this.configurator.validate();
+
+ this.classifier = this.configurator.newLearner(this.classifierRandom.nextInt());
+
+ this.candidates = new Classifier[this.numberOfCandidatesOption.getValue()];
+ for (int i = 0; i < this.numberOfCandidatesOption.getValue(); i++) {
+ // Built one by one rather than copied from a single base, so that
+ // each candidate carries its own random seed.
+ this.candidates[i] = this.configurator.newLearner(this.classifierRandom.nextInt());
+ }
+
+ for (ParameterSpec spec : space.parameters) {
+ switch (spec.type) {
+ case ParameterSpec.TYPE_CATEGORICAL: {
+ int active = this.randomInitialParametersOption.isSet()
+ ? this.classifierRandom.nextInt(spec.values.length)
+ : spec.active;
+ this.classifierParameters.add(new CategoricalParameter(spec.name, spec.values,
+ active, new Random(this.classifierRandom.nextLong())));
+ for (int i = 0; i < this.numberOfCandidatesOption.getValue(); i++) {
+ this.candidatesParameters.get(i).add(new CategoricalParameter(spec.name,
+ spec.values, active, new Random(this.classifierRandom.nextLong())));
+ }
+ break;
+ }
+ case ParameterSpec.TYPE_INT: {
+ int[] range = new int[]{(int) spec.range[0], (int) spec.range[1]};
+ int value = this.randomInitialParametersOption.isSet()
+ ? this.classifierRandom.nextInt(range[1] + 1 - range[0]) + range[0]
+ : (int) spec.value;
+ this.classifierParameters.add(new IntParameter(spec.name, value, range,
+ new Random(this.classifierRandom.nextLong())));
+ for (int i = 0; i < this.numberOfCandidatesOption.getValue(); i++) {
+ this.candidatesParameters.get(i).add(new IntParameter(spec.name, value, range,
+ new Random(this.classifierRandom.nextLong())));
+ }
+ this.numericalParameters++;
+ break;
+ }
+ case ParameterSpec.TYPE_DOUBLE: {
+ double[] range = new double[]{spec.range[0], spec.range[1]};
+ double value = this.randomInitialParametersOption.isSet()
+ ? range[0] + (range[1] - range[0]) * this.classifierRandom.nextDouble()
+ : spec.value;
+ this.classifierParameters.add(new DoubleParameter(spec.name, value, range,
+ new Random(this.classifierRandom.nextLong())));
+ for (int i = 0; i < this.numberOfCandidatesOption.getValue(); i++) {
+ this.candidatesParameters.get(i).add(new DoubleParameter(spec.name, value, range,
+ new Random(this.classifierRandom.nextLong())));
+ }
+ this.numericalParameters++;
+ break;
+ }
+ default:
+ break;
+ }
+ }
+
+ this.classifier = this.configurator.instantiate(this.classifierParameters, this.classifierRandom.nextInt());
+
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not set up " + getClass().getSimpleName()
+ + " from " + ConfigurationSpace.describeSource(this.configurationFileOption.getValue(), this.searchSpaceOption.getValue()) + ": " + e.getMessage(), e);
+ }
+ }
+
+ public void deepCopyList(int bestPerforming) {
+ for (int i = 0; i < this.classifierParameters.size(); i++) {
+ Parameter parameterS = this.candidatesParameters.get(bestPerforming).get(i);
+ Parameter parameterC = this.classifierParameters.get(i);
+ switch (parameterS.type) {
+ case Parameter.TYPE_INT:
+ ((IntParameter) parameterC).value = ((IntParameter) parameterS).value;
+ ((IntParameter) parameterC).range = ((IntParameter) parameterS).range;
+ break;
+ case Parameter.TYPE_DOUBLE:
+ ((DoubleParameter) parameterC).value = ((DoubleParameter) parameterS).value;
+ ((DoubleParameter) parameterC).range = ((DoubleParameter) parameterS).range;
+ break;
+ case Parameter.TYPE_CATEGORICAL:
+ ((CategoricalParameter) parameterC).values = ((CategoricalParameter) parameterS).values;
+ ((CategoricalParameter) parameterC).active = ((CategoricalParameter) parameterS).active;
+ break;
+ }
+ }
+ }
+
+ public void checkParameterChange() {
+ try {
+ double best = -Double.MAX_VALUE;
+ double incumbent = -Double.MAX_VALUE;
+ int bestPerforming = 0;
+
+ if (this.statesEvaluated != 0) {
+ best = getCandidateScore(0);
+ for (int i = 1; i < this.activeCandidates; i++) {
+ double current = getCandidateScore(i);
+ if (current > best) {
+ bestPerforming = i;
+ best = current;
+ }
+ }
+ incumbent = getClassifierScore();
+ }
+
+ if (!Double.isNaN(best) && best > incumbent) {
+ this.swapClassifiers(bestPerforming);
+ }
+
+ if (this.pendingActiveCandidates > 0) {
+ this.activeCandidates = this.pendingActiveCandidates;
+ this.pendingActiveCandidates = -1;
+ }
+
+ boolean live = this.configurator.canApplyLive();
+
+ if (live) {
+ for (int i = 0; i < this.activeCandidates; i++) {
+ if (this.resetLearningOption.isSet()) {
+ this.candidates[i].resetLearning();
+ } else {
+ this.candidates[i] = this.classifier.copy();
+ LearnerConfigurator.reseedCopy(this.candidates[i], this.classifierRandom.nextInt());
+ }
+ }
+ }
+
+ for (int i = 0; i < this.activeCandidates; i++) {
+ ArrayList listp = this.candidatesParameters.get(i);
+ for (Parameter parameter : listp) {
+ this.changeStateParameter(parameter, i);
+ }
+ this.appliedParameters[i] = listp;
+ }
+
+ if (!live) {
+ for (int i = 0; i < this.activeCandidates; i++) {
+ this.candidates[i] = this.configurator.instantiate(this.appliedParameters[i], this.classifierRandom.nextInt());
+ }
+ }
+
+ this.applyFreezeMask();
+
+ this.evaluatorClassifier.reset();
+ for (int i = 0; i < this.activeCandidates; i++) {
+ this.evaluatorCandidates[i].reset();
+ }
+
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to update candidates in "
+ + getClass().getSimpleName() + ": " + e.getMessage(), e);
+ }
+ }
+
+ public void swapClassifiers(int bestPerforming) {
+ this.classifier = this.candidates[bestPerforming].copy();
+ this.deepCopyList(bestPerforming);
+ }
+
+ @Override
+ public void changeStateParameter(Parameter parameter, int index) {
+ parameter.changeParameter();
+ if (this.configurator.canApplyLive()) {
+ this.configurator.applyLive(this.candidates[index], parameter);
+ }
+ }
+
+ @Override
+ public void trainOnInstanceImpl(Instance inst) {
+ double[] incumbentVotes = driftDetector != null ? getVotesForInstance(inst) : null;
+ initExecutor();
+ this.evaluationInstances++;
+
+ if ((this.statesEvaluated == 0) || this.evaluationInstances >= this.periodicityOption.getValue()) {
+ this.checkParameterChange();
+ this.statesEvaluated++;
+ this.evaluationInstances = 0;
+ }
+
+ InstanceExample example = new InstanceExample(inst);
+ this.evaluatorClassifier.addResult(example, this.classifier.getVotesForInstance(inst));
+ for (int i = 0; i < this.activeCandidates; i++) {
+ this.evaluatorCandidates[i].addResult(example, this.candidates[i].getVotesForInstance(inst));
+ }
+
+ if (this.executor == null) {
+ this.classifier.trainOnInstance(inst);
+ for (int i = 0; i < this.activeCandidates; i++) {
+ this.candidates[i].trainOnInstance(inst);
+ }
+ } else {
+ // The incumbent is an independent model, so it joins the batch as one
+ // more task. Every model owns its state, hence no synchronization here.
+ Collection trainers = new ArrayList();
+ trainers.add(new TrainingRunnable(this.classifier, inst));
+ for (int i = 0; i < this.activeCandidates; i++) {
+ trainers.add(new TrainingRunnable(this.candidates[i], inst));
+ }
+ try {
+ this.executor.invokeAll(trainers);
+ } catch (InterruptedException ex) {
+ throw new RuntimeException("Could not call invokeAll() on training threads.");
+ }
+ }
+
+ this.instanceCount++;
+
+ if (incumbentVotes != null) checkDrift(inst, incumbentVotes);
+ }
+
+ @Override
+ public long measureByteSize() {
+ long candidateSize = 0;
+ for (int i = 0; i < this.activeCandidates; i++) {
+ candidateSize += this.candidates[i].measureByteSize();
+ }
+ return SizeOf.sizeOf(this) + this.classifier.measureByteSize() + candidateSize;
+ }
+
+ @Override
+ protected Measurement[] getModelMeasurementsImpl() {
+ ArrayList parameters = new ArrayList<>();
+ parameters.add(new Measurement("driftsDetected", driftsDetected));
+ for (Measurement m : this.classifier.getModelMeasurements()) {
+ parameters.add(m);
+ }
+ for (Parameter p : this.classifierParameters) {
+ switch (p.type) {
+ case Parameter.TYPE_INT:
+ parameters.add(new Measurement(p.name, ((IntParameter) p).value));
+ break;
+ case Parameter.TYPE_DOUBLE:
+ parameters.add(new Measurement(p.name, ((DoubleParameter) p).value));
+ break;
+ case Parameter.TYPE_CATEGORICAL:
+ parameters.add(new Measurement(p.name, ((CategoricalParameter) p).active));
+ break;
+ }
+ }
+ Measurement[] measurements = new Measurement[parameters.size()];
+ measurements = parameters.toArray(measurements);
+ return measurements;
+ }
+
+ @Override
+ public void getModelDescription(StringBuilder out, int indent) {
+ }
+
+ @Override
+ public ArrayList getReferenceParameters() { return this.classifierParameters; }
+
+ @Override
+ public ArrayList> getCandidateParameters() { return this.candidatesParameters; }
+
+ @Override
+ public double getCandidateScore(int i) {
+ return MetricUtils.getRegressionScore(this.evaluatorCandidates[i].getPerformanceMeasurements(),
+ this.metricOption.getChosenIndex());
+ }
+
+ @Override
+ public double getClassifierScore() {
+ return MetricUtils.getRegressionScore(this.evaluatorClassifier.getPerformanceMeasurements(),
+ this.metricOption.getChosenIndex());
+ }
+
+ @Override
+ public int getNumberOfCandidates() { return this.numberOfCandidatesOption.getValue(); }
+
+ @Override
+ public long getStatesEvaluatedCount() { return this.statesEvaluated; }
+
+ @Override
+ public int getEvaluationInstancesCount() { return this.evaluationInstances; }
+
+ @Override
+ public int getPeriodicity() { return this.periodicityOption.getValue(); }
+
+ @Override
+ public Classifier getMainClassifier() { return this.classifier; }
+
+ @Override
+ public String getConfigurationFile() { return this.configurationFileOption.getValue(); }
+
+ /**
+ * Feeds the incumbent's prediction error to the change detector and, when a
+ * drift is signalled, reinitializes the whole search from the search space.
+ * The signal is the absolute error |y - y_pred|.
+ */
+ private void checkDrift(Instance inst, double[] incumbentVotes) {
+ double predicted = incumbentVotes.length > 0 ? incumbentVotes[0] : 0.0;
+ driftDetector.input(Math.abs(predicted - inst.classValue()));
+ if (driftDetector.getChange()) {
+ driftsDetected++;
+ resetLearningImpl();
+ }
+ }
+
+ /**
+ * Inner class to assist with the multi-thread execution.
+ */
+ protected class TrainingRunnable implements Runnable, Callable {
+ final private Classifier learner;
+ final private Instance instance;
+
+ public TrainingRunnable(Classifier learner, Instance instance) {
+ this.learner = learner;
+ this.instance = instance;
+ }
+
+ @Override
+ public void run() {
+ this.learner.trainOnInstance(this.instance);
+ }
+
+ @Override
+ public Integer call() {
+ run();
+ return 0;
+ }
+ }
+}
diff --git a/moa/src/main/java/moa/classifiers/AutoML/SSPTClassifier.java b/moa/src/main/java/moa/classifiers/AutoML/SSPTClassifier.java
new file mode 100644
index 000000000..29a07bc52
--- /dev/null
+++ b/moa/src/main/java/moa/classifiers/AutoML/SSPTClassifier.java
@@ -0,0 +1,765 @@
+/*
+ * SSPTClassifier.java
+ * Copyright (C) 2026 University of Waikato, Hamilton, New Zealand
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package moa.classifiers.AutoML;
+
+import com.github.javacliparser.FileOption;
+import com.github.javacliparser.FlagOption;
+import com.github.javacliparser.FloatOption;
+import com.github.javacliparser.IntOption;
+import com.github.javacliparser.MultiChoiceOption;
+import com.github.javacliparser.StringOption;
+import com.yahoo.labs.samoa.instances.Instance;
+import moa.capabilities.CapabilitiesHandler;
+import moa.classifiers.AbstractClassifier;
+import moa.classifiers.AutoML.Parameters.*;
+import moa.classifiers.AutoML.space.ConfigurationSpace;
+import moa.classifiers.AutoML.space.LearnerConfigurator;
+import moa.classifiers.AutoML.space.ParameterSpec;
+import moa.classifiers.Classifier;
+import moa.classifiers.core.driftdetection.ChangeDetector;
+import moa.classifiers.MultiClassClassifier;
+import moa.core.InstanceExample;
+import moa.core.Measurement;
+import moa.core.SizeOf;
+import moa.evaluation.BasicClassificationPerformanceEvaluator;
+import moa.options.ClassOption;
+
+import java.io.Serializable;
+import java.util.*;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Self-adjusting Nelder-Mead simplex search over a streaming hyperparameter
+ * space: three vertices are trained in parallel, and each evaluation window
+ * replaces the worst vertex by the best of the reflection, expansion,
+ * contraction and shrink points.
+ *
+ * Candidates are configured through {@link LearnerConfigurator}, i.e. by
+ * writing MOA {@link com.github.javacliparser.Option}s, so any MOA classifier
+ * can be tuned as shipped.
+ *
+ *
See details in:
Bruno Veloso, Joao Gama, Benedita Malheiro, Joao
+ * Vinagre. Hyperparameter self-tuning for data streams. In Information Fusion,
+ * 76:75-86, DOI: 10.1016/j.inffus.2021.04.011, Elsevier, 2021.
+ *
+ * @author Daniel Nowak Assis (daniel dot nowak-assis at lip6 dot fr)
+ */
+public class SSPTClassifier extends AbstractClassifier implements MultiClassClassifier,
+ CapabilitiesHandler, Serializable, HPOMethod {
+
+ public FileOption configurationFileOption = new FileOption("configurationFile", 'f',
+ "Search space in JSON format.", null, ".json", false);
+
+ public StringOption searchSpaceOption = new StringOption("searchSpace", 's',
+ "Search space as inline JSON. Takes precedence over configurationFile when set,"
+ + " so that a caller holding the space in memory need not write a file.", "");
+
+ public IntOption periodicityOption = new IntOption("periodicity", 'g',
+ "Number of instances between simplex updates.", 1000, 1, Integer.MAX_VALUE);
+
+ public FloatOption convergenceSphereOption = new FloatOption("convergenceSphere", 'c',
+ "Convergence threshold: squared distance between centroids.", 0.001, 0.0, Double.MAX_VALUE);
+
+ public MultiChoiceOption metricOption = new MultiChoiceOption("metric", 'm',
+ "Metric to optimize the model.", MetricUtils.METRIC_NAMES, MetricUtils.METRIC_DESCRIPTIONS, 4);
+
+ public FlagOption resetModelsOption = new FlagOption("resetModels", 'r',
+ "Create fresh classifiers for expanded points instead of warm-starting from best.");
+
+ public FlagOption verboseOption = new FlagOption("verbose", 'v',
+ "Print simplex events to stdout.");
+
+ public FlagOption driftDetectionOption = new FlagOption("driftDetection", 'd',
+ "Enable drift detection on the incumbent's prediction error.");
+
+ public ClassOption driftDetectorOption = new ClassOption("driftDetector", 'D',
+ "Change detector to use when drift detection is enabled; on a detected"
+ + " drift the whole search is reinitialized from the search space.",
+ ChangeDetector.class, "ADWINChangeDetector");
+
+ public IntOption numberOfJobsOption = new IntOption("numberOfJobs", 'j',
+ "Total number of concurrent jobs used for processing (-1 = as much as possible, 0 = do not use multithreading)",
+ 1, -1, Integer.MAX_VALUE);
+
+ protected static final int SINGLE_THREAD = 0;
+
+ /** Largest batch trained per instance: the 3 vertices plus the 6 expansion points. */
+ protected static final int MAX_MODELS_PER_INSTANCE = 9;
+
+ // ========== INNER CLASS ==========
+
+ protected static class SimplexEntry implements Serializable {
+ Classifier model;
+ BasicClassificationPerformanceEvaluator evaluator;
+ ArrayList params;
+ long instancesSeen;
+
+ SimplexEntry(Classifier model, BasicClassificationPerformanceEvaluator evaluator,
+ ArrayList params) {
+ this.model = model;
+ this.evaluator = evaluator;
+ this.params = params;
+ this.instancesSeen = 0;
+ }
+
+ double getMetric(int metricIndex) {
+ if (instancesSeen == 0) return Double.NEGATIVE_INFINITY;
+ return MetricUtils.getScore(evaluator.getPerformanceMeasurements(), metricIndex);
+ }
+
+ void addResult(InstanceExample example, double[] votes) {
+ evaluator.addResult(example, votes);
+ instancesSeen++;
+ }
+
+ /** Forget what this entry scored, so a fresh window starts even. */
+ void resetEvaluation() {
+ evaluator.reset();
+ instancesSeen = 0;
+ }
+ }
+
+ // ========== FIELDS ==========
+
+ protected SimplexEntry[] simplex;
+ protected HashMap expanded;
+
+ protected ArrayList lastCentroid;
+
+ protected ConfigurationSpace space;
+
+ /** Boundary through which every learner is configured. */
+ protected LearnerConfigurator configurator;
+
+ protected long instanceCount;
+ protected int evaluationInstances;
+ protected boolean converged;
+
+ protected boolean[] optimizeMask;
+
+ /** Detector on the incumbent's error; {@code null} unless drift detection is on. */
+ protected ChangeDetector driftDetector;
+
+ /** Cumulative number of drifts signalled over the run; not reset by a restart. */
+ protected long driftsDetected;
+
+ /**
+ * Pool training the simplex vertices and expansion points concurrently;
+ * {@code null} when running single-threaded. Transient because an executor
+ * cannot be serialized, so it is (re)created by {@link #initExecutor()}.
+ */
+ protected transient ExecutorService executor;
+
+ @Override
+ public void setOptimizableParameters(boolean[] mask) { this.optimizeMask = mask; }
+
+ // ========== LIFECYCLE ==========
+
+ @Override
+ public boolean isRandomizable() {
+ return true;
+ }
+
+ /**
+ * Creates the training pool on first use, and again after deserialization.
+ * A pool larger than the batch trained per instance would leave threads idle,
+ * so the requested job count is capped at {@link #MAX_MODELS_PER_INSTANCE}.
+ */
+ protected void initExecutor() {
+ if (this.executor != null) return;
+ int numberOfJobs = this.numberOfJobsOption.getValue() == -1
+ ? Runtime.getRuntime().availableProcessors()
+ : this.numberOfJobsOption.getValue();
+ numberOfJobs = Math.min(numberOfJobs, MAX_MODELS_PER_INSTANCE);
+ // SINGLE_THREAD and requesting a single thread are equivalent: training
+ // then happens in-place and this.executor stays null.
+ if (numberOfJobs != SINGLE_THREAD && numberOfJobs != 1) {
+ // Daemon threads: a live pool must not keep the JVM alive after the
+ // task that ran this learner has finished.
+ this.executor = Executors.newFixedThreadPool(numberOfJobs, runnable -> {
+ Thread thread = new Thread(runnable);
+ thread.setDaemon(true);
+ return thread;
+ });
+ }
+ }
+
+ @Override
+ public void cleanThreads() {
+ if (this.executor != null) {
+ this.executor.shutdownNow();
+ this.executor = null;
+ }
+ }
+
+ @Override
+ public void resetLearningImpl() {
+ cleanThreads(); // shut down any pool from a previous reset before creating a new one
+ converged = false;
+ instanceCount = 0;
+ evaluationInstances = 0;
+ lastCentroid = null;
+ expanded = null;
+
+ driftDetector = driftDetectionOption.isSet()
+ ? ((ChangeDetector) getPreparedClassOption(driftDetectorOption)).copy()
+ : null;
+
+ setConfigurations();
+ initializeSimplex();
+
+ }
+
+ @Override
+ public void setConfigurations() {
+ try {
+ this.space = ConfigurationSpace.resolve(configurationFileOption.getValue(), searchSpaceOption.getValue());
+ this.configurator = new LearnerConfigurator(this.space);
+ this.configurator.validate();
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not set up " + getClass().getSimpleName()
+ + " from " + ConfigurationSpace.describeSource(configurationFileOption.getValue(), searchSpaceOption.getValue()) + ": " + e.getMessage(), e);
+ }
+ }
+
+
+ @Override
+ public void checkParameterChange() {}
+
+ @Override
+ public void swapClassifiers(int bestPerforming) {}
+
+ @Override
+ public void deepCopyList(int bestPerforming) {}
+
+ @Override
+ public void changeStateParameter(Parameter parameter, int index) {}
+
+ private void initializeSimplex() {
+ simplex = new SimplexEntry[3];
+ for (int i = 0; i < 3; i++) {
+ ArrayList params = createRandomParams();
+ Classifier model = createModelWithParams(params);
+ simplex[i] = new SimplexEntry(model, newEvaluator(), params);
+ }
+ if (verboseOption.isSet())
+ System.out.println("SSPT: Initialized simplex with 3 models");
+ }
+
+ private ArrayList createRandomParams() {
+ ArrayList params = new ArrayList<>();
+ for (ParameterSpec spec : this.space.parameters) {
+ String name = spec.name;
+ switch (spec.type) {
+ case ParameterSpec.TYPE_INT: {
+ int[] range = {(int) spec.range[0], (int) spec.range[1]};
+ int value = classifierRandom.nextInt(range[1] - range[0] + 1) + range[0];
+ params.add(new IntParameter(name, value, range, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case ParameterSpec.TYPE_DOUBLE: {
+ double[] range = {spec.range[0], spec.range[1]};
+ double value = range[0] + classifierRandom.nextDouble() * (range[1] - range[0]);
+ params.add(new DoubleParameter(name, value, range, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case ParameterSpec.TYPE_CATEGORICAL: {
+ String[] values = spec.values;
+ int active = classifierRandom.nextInt(values.length);
+ params.add(new CategoricalParameter(name, values, active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return params;
+ }
+
+ /** A model built from scratch at {@code params}. */
+ private Classifier createModelWithParams(ArrayList params) {
+ return this.configurator.instantiate(params, this.classifierRandom.nextInt());
+ }
+
+ /**
+ * Reconfigure a warm-started model in place, returning the model to use.
+ * Falls back to rebuilding when the space contains a hyperparameter the
+ * learner only reads at construction time, which by definition cannot take
+ * effect on a model that is already training.
+ */
+ private Classifier applyParamsToModel(Classifier model, ArrayList params) {
+ if (!this.configurator.canApplyLive()) {
+ return this.configurator.instantiate(params, this.classifierRandom.nextInt());
+ }
+ this.configurator.applyLive(model, params);
+ return model;
+ }
+
+ private BasicClassificationPerformanceEvaluator newEvaluator() {
+ BasicClassificationPerformanceEvaluator eval = new BasicClassificationPerformanceEvaluator();
+ MetricUtils.configure(eval);
+ return eval;
+ }
+
+ // ========== TRAINING ==========
+
+ @Override
+ public double[] getVotesForInstance(Instance inst) {
+ return simplex[0].model.getVotesForInstance(inst);
+ }
+
+ @Override
+ public void trainOnInstanceImpl(Instance inst) {
+ double[] incumbentVotes = driftDetector != null ? getVotesForInstance(inst) : null;
+ initExecutor();
+ instanceCount++;
+ evaluationInstances++;
+ InstanceExample example = new InstanceExample(inst);
+
+ if (converged) {
+ trainConverged(inst, example);
+ }
+ else {
+ trainNotConverged(inst, example);
+ }
+
+ if (incumbentVotes != null) checkDrift(inst, incumbentVotes);
+ }
+
+ private void trainConverged(Instance inst, InstanceExample example) {
+ SimplexEntry best = simplex[0];
+ double[] votes = best.model.getVotesForInstance(inst);
+ best.addResult(example, votes);
+ best.model.trainOnInstance(inst);
+ }
+
+ private void trainNotConverged(Instance inst, InstanceExample example) {
+
+ // Vertices and expansion points form a single batch: two barriers per
+ // instance would halve the gain on such a small number of models.
+ Collection trainers = this.executor == null
+ ? null : new ArrayList();
+
+ for (SimplexEntry entry : simplex) {
+ double[] votes = entry.model.getVotesForInstance(inst);
+ entry.addResult(example, votes);
+
+ if (trainers == null) entry.model.trainOnInstance(inst);
+ else trainers.add(new TrainingRunnable(entry.model, inst));
+ }
+
+ if (expanded != null) {
+ for (SimplexEntry entry : expanded.values()) {
+ double[] votes = entry.model.getVotesForInstance(inst);
+ entry.addResult(example, votes);
+
+ if (trainers == null) entry.model.trainOnInstance(inst);
+ else trainers.add(new TrainingRunnable(entry.model, inst));
+ }
+ }
+
+ if (trainers != null) {
+ try {
+ this.executor.invokeAll(trainers);
+ } catch (InterruptedException ex) {
+ throw new RuntimeException("Could not call invokeAll() on training threads.");
+ }
+ }
+
+ if (evaluationInstances >= periodicityOption.getValue()) {
+ evaluationInstances = 0;
+ boolean moved = updateSimplex();
+ if (moved && checkConvergence()) {
+ if (verboseOption.isSet())
+ System.out.println("SSPT: Converged at instance " + instanceCount);
+ converged = true;
+ }
+ }
+ }
+
+ // ========== SIMPLEX UPDATE ==========
+
+ /**
+ * Advances the simplex, alternating between laying down the Nelder-Mead
+ * candidate points and choosing among them.
+ *
+ * The two phases must be separate windows. {@link #createExpanded()}
+ * builds the reflection, expansion, contraction, shrink and midpoint
+ * models but trains none of them, and
+ * {@link #applyNelderMeadOperators()} ranks them by measured performance -
+ * so applying the operators in the same call that creates the points
+ * compares six models that have seen no instances at all. Every
+ * comparison then fails, the operators fall through to their last branch,
+ * and the simplex is overwritten with untrained shrink and midpoint models
+ * on every update, whatever the data says.
+ *
+ *
Splitting the phases lets the candidate points train alongside the
+ * vertices for a full window first, which is what makes the choice between
+ * them meaningful.
+ *
+ * @return whether the simplex moved, i.e. whether this call applied
+ * the operators rather than laying the candidate points down
+ */
+ private boolean updateSimplex() {
+ if (expanded == null || expanded.isEmpty()) {
+ sortSimplex();
+ lastCentroid = computeCentroid();
+ expanded = createExpanded();
+ // The vertices have been accumulating since they were created and
+ // the candidate points have seen nothing, so the next window is
+ // scored from a common start for all nine models.
+ for (SimplexEntry vertex : simplex) vertex.resetEvaluation();
+ return false;
+ }
+
+ applyNelderMeadOperators();
+ expanded = null;
+ return true;
+ }
+
+ private void sortSimplex() {
+ int metric = metricOption.getChosenIndex();
+ // Insertion sort (3 elements, descending by metric)
+ for (int i = 1; i < 3; i++) {
+ SimplexEntry key = simplex[i];
+ int j = i - 1;
+ while (j >= 0 && simplex[j].getMetric(metric) < key.getMetric(metric)) {
+ simplex[j + 1] = simplex[j];
+ j--;
+ }
+ simplex[j + 1] = key;
+ }
+ }
+
+ private ArrayList computeCentroid() {
+ ArrayList centroid = cloneParams(simplex[0].params);
+ for (int i = 0; i < centroid.size(); i++) {
+ Parameter p = centroid.get(i);
+ if (p.type == 0) {
+ double sum = 0;
+ for (SimplexEntry e : simplex) sum += ((IntParameter) e.params.get(i)).value;
+ int val = clampInt((int) Math.round(sum / 3.0),
+ ((IntParameter) p).range[0], ((IntParameter) p).range[1]);
+ ((IntParameter) p).value = val;
+ } else if (p.type == 1) {
+ double sum = 0;
+ for (SimplexEntry e : simplex) sum += ((DoubleParameter) e.params.get(i)).value;
+ double val = clampDouble(sum / 3.0,
+ ((DoubleParameter) p).range[0], ((DoubleParameter) p).range[1]);
+ ((DoubleParameter) p).value = val;
+ } else {
+ // Mode for categorical
+ Map counts = new HashMap<>();
+ for (SimplexEntry e : simplex)
+ counts.merge(((CategoricalParameter) e.params.get(i)).active, 1, Integer::sum);
+ int mode = Collections.max(counts.entrySet(), Map.Entry.comparingByValue()).getKey();
+ ((CategoricalParameter) p).active = mode;
+ }
+ }
+ return centroid;
+ }
+
+ private boolean checkConvergence() {
+ if (lastCentroid == null) return false;
+ ArrayList current = computeCentroid();
+ double distSq = 0;
+ for (int i = 0; i < lastCentroid.size(); i++) {
+ Parameter lp = lastCentroid.get(i);
+ Parameter cp = current.get(i);
+ if (lp.type == 0) {
+ double diff = ((IntParameter) lp).value - ((IntParameter) cp).value;
+ distSq += diff * diff;
+ } else if (lp.type == 1) {
+ double diff = ((DoubleParameter) lp).value - ((DoubleParameter) cp).value;
+ distSq += diff * diff;
+ }
+ }
+ double threshold = convergenceSphereOption.getValue();
+ return distSq < threshold * threshold;
+ }
+
+ // ========== NELDER-MEAD ==========
+
+ private interface DoubleOp {
+ double apply(double a, double b);
+ }
+
+ private HashMap createExpanded() {
+ HashMap exp = new HashMap<>();
+
+ ArrayList bestP = simplex[0].params;
+ ArrayList goodP = simplex[1].params;
+ ArrayList worstP = simplex[2].params;
+
+ ArrayList midP = combine(bestP, goodP, (a, b) -> (a + b) / 2.0);
+ ArrayList reflP = combine(midP, worstP, (a, b) -> 2 * a - b);
+ ArrayList expP = combine(reflP, midP, (a, b) -> 2 * a - b);
+ ArrayList shrP = combine(bestP, worstP, (a, b) -> (a + b) / 2.0);
+ ArrayList cont1P = combine(midP, worstP, (a, b) -> (a + b) / 2.0);
+ ArrayList cont2P = combine(midP, reflP, (a, b) -> (a + b) / 2.0);
+
+ String[] names = {"midpoint", "reflection", "expansion", "shrink", "contraction1", "contraction2"};
+ @SuppressWarnings("unchecked")
+ ArrayList[] paramSets = new ArrayList[]{midP, reflP, expP, shrP, cont1P, cont2P};
+
+ // Freeze the hyperparameters outside the optimized subset to the best
+ // simplex vertex before any model is built or evaluated.
+ for (ArrayList ps : paramSets)
+ HPOMethod.freezeToIncumbent(this.optimizeMask, ps, simplex[0].params);
+
+ for (int i = 0; i < names.length; i++) {
+ SimplexEntry entry;
+ if (resetModelsOption.isSet()) {
+ Classifier model = createModelWithParams(paramSets[i]);
+ entry = new SimplexEntry(model, newEvaluator(), paramSets[i]);
+ } else {
+ // Warm-start: copy best model's learned state, change parameters
+ Classifier model = simplex[0].model.copy();
+ LearnerConfigurator.reseedCopy(model, this.classifierRandom.nextInt());
+ model = applyParamsToModel(model, paramSets[i]);
+ entry = new SimplexEntry(model, newEvaluator(), paramSets[i]);
+ }
+ exp.put(names[i], entry);
+ }
+ return exp;
+ }
+
+ private void applyNelderMeadOperators() {
+ int metric = metricOption.getChosenIndex();
+ SimplexEntry b = simplex[0];
+ SimplexEntry g = simplex[1];
+ SimplexEntry w = simplex[2];
+ SimplexEntry r = expanded.get("reflection");
+ SimplexEntry c1 = expanded.get("contraction1");
+ SimplexEntry c2 = expanded.get("contraction2");
+ SimplexEntry e = expanded.get("expansion");
+ SimplexEntry s = expanded.get("shrink");
+ SimplexEntry mid = expanded.get("midpoint");
+
+ SimplexEntry contraction = c1.getMetric(metric) > c2.getMetric(metric) ? c1 : c2;
+
+ if (r.getMetric(metric) > g.getMetric(metric)) {
+ if (b.getMetric(metric) > r.getMetric(metric)) {
+ simplex[2] = r;
+ } else {
+ simplex[2] = e.getMetric(metric) > b.getMetric(metric) ? e : r;
+ }
+ } else {
+ if (r.getMetric(metric) > w.getMetric(metric)) {
+ simplex[2] = r;
+ } else {
+ if (contraction.getMetric(metric) > w.getMetric(metric)) {
+ simplex[2] = contraction;
+ } else {
+ simplex[2] = s;
+ simplex[1] = mid;
+ }
+ }
+ }
+ sortSimplex();
+ }
+
+ // ========== HELPERS ==========
+
+ private ArrayList combine(ArrayList p1, ArrayList p2, DoubleOp op) {
+ ArrayList result = new ArrayList<>();
+ for (int i = 0; i < p1.size(); i++) {
+ Parameter a = p1.get(i);
+ Parameter b = p2.get(i);
+ switch (a.type) {
+ case Parameter.TYPE_INT: {
+ int[] range = ((IntParameter) a).range;
+ double combined = op.apply(((IntParameter) a).value, ((IntParameter) b).value);
+ int val = clampInt((int) Math.round(combined), range[0], range[1]);
+ result.add(new IntParameter(a.name, val, range, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_DOUBLE: {
+ double[] range = ((DoubleParameter) a).range;
+ double val = clampDouble(
+ op.apply(((DoubleParameter) a).value, ((DoubleParameter) b).value),
+ range[0], range[1]);
+ result.add(new DoubleParameter(a.name, val, range, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_CATEGORICAL: {
+ CategoricalParameter ca = (CategoricalParameter) a;
+ CategoricalParameter cb = (CategoricalParameter) b;
+ int active = classifierRandom.nextBoolean() ? ca.active : cb.active;
+ result.add(new CategoricalParameter(a.name, ca.values, active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return result;
+ }
+
+ private ArrayList cloneParams(ArrayList source) {
+ ArrayList copy = new ArrayList<>();
+ for (Parameter p : source) {
+ switch (p.type) {
+ case Parameter.TYPE_INT: {
+ IntParameter ip = (IntParameter) p;
+ copy.add(new IntParameter(ip.name, ip.value, ip.range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_DOUBLE: {
+ DoubleParameter dp = (DoubleParameter) p;
+ copy.add(new DoubleParameter(dp.name, dp.value, dp.range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_CATEGORICAL: {
+ CategoricalParameter cp = (CategoricalParameter) p;
+ copy.add(new CategoricalParameter(cp.name, cp.values.clone(), cp.active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return copy;
+ }
+
+ private int clampInt(int val, int min, int max) {
+ return Math.max(min, Math.min(max, val));
+ }
+
+ private double clampDouble(double val, double min, double max) {
+ return Math.max(min, Math.min(max, val));
+ }
+
+ private int argmax(double[] arr) {
+ int best = 0;
+ for (int i = 1; i < arr.length; i++)
+ if (arr[i] > arr[best]) best = i;
+ return best;
+ }
+
+ // ========== MOA INTERFACE ==========
+
+ @Override
+ protected Measurement[] getModelMeasurementsImpl() {
+ ArrayList measurements = new ArrayList<>();
+ measurements.add(new Measurement("driftsDetected", driftsDetected));
+ for (Measurement m : simplex[0].model.getModelMeasurements())
+ measurements.add(m);
+ for (Parameter p : simplex[0].params) {
+ switch (p.type) {
+ case Parameter.TYPE_INT:
+ measurements.add(new Measurement(p.name, ((IntParameter) p).value));
+ break;
+ case Parameter.TYPE_DOUBLE:
+ measurements.add(new Measurement(p.name, ((DoubleParameter) p).value));
+ break;
+ case Parameter.TYPE_CATEGORICAL:
+ measurements.add(new Measurement(p.name, ((CategoricalParameter) p).active));
+ break;
+ }
+ }
+ return measurements.toArray(new Measurement[0]);
+ }
+
+ @Override
+ public void getModelDescription(StringBuilder out, int indent) {
+ }
+
+ @Override
+ public long measureByteSize() {
+ long size = SizeOf.sizeOf(this);
+ for (SimplexEntry entry : simplex)
+ size += entry.model.measureByteSize();
+ return size;
+ }
+
+ @Override
+ public double getCandidateScore(int i) {
+ return simplex[i].getMetric(metricOption.getChosenIndex());
+ }
+
+ @Override
+ public double getClassifierScore() {
+ return simplex[0].getMetric(metricOption.getChosenIndex());
+ }
+
+ @Override
+ public int getNumberOfCandidates() { return simplex == null ? 0 : simplex.length; }
+
+ @Override
+ public long getStatesEvaluatedCount() { return instanceCount / periodicityOption.getValue(); }
+
+ @Override
+ public int getEvaluationInstancesCount() { return evaluationInstances; }
+
+ @Override
+ public int getPeriodicity() { return periodicityOption.getValue(); }
+
+ @Override
+ public Classifier getMainClassifier() { return simplex[0].model; }
+
+ @Override
+ public ArrayList getReferenceParameters() { return simplex[0].params; }
+
+ @Override
+ public String getConfigurationFile() { return this.configurationFileOption.getValue();}
+
+
+ @Override
+ public ArrayList> getCandidateParameters() {
+ ArrayList> list = new ArrayList<>();
+ for (SimplexEntry e : simplex) list.add(e.params);
+ return list;
+ }
+
+ /**
+ * Feeds the incumbent's prediction error to the change detector and, when a
+ * drift is signalled, reinitializes the whole search from the search space.
+ * The signal is the 0/1 misclassification indicator.
+ */
+ private void checkDrift(Instance inst, double[] incumbentVotes) {
+ driftDetector.input(argmax(incumbentVotes) != (int) inst.classValue() ? 1.0 : 0.0);
+ if (driftDetector.getChange()) {
+ driftsDetected++;
+ if (verboseOption.isSet())
+ System.out.println("SSPT: Drift detected at instance " + instanceCount
+ + ", reinitializing");
+ resetLearningImpl();
+ }
+ }
+
+ /**
+ * Inner class to assist with the multi-thread execution.
+ */
+ protected class TrainingRunnable implements Runnable, Callable {
+ final private Classifier learner;
+ final private Instance instance;
+
+ public TrainingRunnable(Classifier learner, Instance instance) {
+ this.learner = learner;
+ this.instance = instance;
+ }
+
+ @Override
+ public void run() {
+ this.learner.trainOnInstance(this.instance);
+ }
+
+ @Override
+ public Integer call() {
+ run();
+ return 0;
+ }
+ }
+}
diff --git a/moa/src/main/java/moa/classifiers/AutoML/SSPTRegressor.java b/moa/src/main/java/moa/classifiers/AutoML/SSPTRegressor.java
new file mode 100644
index 000000000..743617197
--- /dev/null
+++ b/moa/src/main/java/moa/classifiers/AutoML/SSPTRegressor.java
@@ -0,0 +1,748 @@
+/*
+ * SSPTRegressor.java
+ * Copyright (C) 2026 University of Waikato, Hamilton, New Zealand
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package moa.classifiers.AutoML;
+
+import com.github.javacliparser.FileOption;
+import com.github.javacliparser.FlagOption;
+import com.github.javacliparser.FloatOption;
+import com.github.javacliparser.IntOption;
+import com.github.javacliparser.MultiChoiceOption;
+import com.github.javacliparser.StringOption;
+import com.yahoo.labs.samoa.instances.Instance;
+import moa.capabilities.CapabilitiesHandler;
+import moa.classifiers.AbstractClassifier;
+import moa.classifiers.AutoML.Parameters.*;
+import moa.classifiers.AutoML.space.ConfigurationSpace;
+import moa.classifiers.AutoML.space.LearnerConfigurator;
+import moa.classifiers.AutoML.space.ParameterSpec;
+import moa.classifiers.Classifier;
+import moa.classifiers.core.driftdetection.ChangeDetector;
+import moa.classifiers.Regressor;
+import moa.core.InstanceExample;
+import moa.core.Measurement;
+import moa.core.SizeOf;
+import moa.evaluation.BasicRegressionPerformanceEvaluator;
+import moa.options.ClassOption;
+
+import java.io.Serializable;
+import java.util.*;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Regression counterpart of {@link SSPTClassifier}: a Nelder-Mead simplex over
+ * the streaming hyperparameter space, scored with the regression metrics of
+ * {@link MetricUtils}.
+ *
+ * See details in:
Bruno Veloso, Joao Gama, Benedita Malheiro, Joao
+ * Vinagre. Hyperparameter self-tuning for data streams. In Information Fusion,
+ * 76:75-86, DOI: 10.1016/j.inffus.2021.04.011, Elsevier, 2021.
+ *
+ * @author Daniel Nowak Assis (daniel dot nowak-assis at lip6 dot fr)
+ */
+public class SSPTRegressor extends AbstractClassifier implements Regressor,
+ CapabilitiesHandler, Serializable, HPOMethod {
+
+ public FileOption configurationFileOption = new FileOption("configurationFile", 'f',
+ "Search space in JSON format.", null, ".json", false);
+
+ public StringOption searchSpaceOption = new StringOption("searchSpace", 's',
+ "Search space as inline JSON. Takes precedence over configurationFile when set,"
+ + " so that a caller holding the space in memory need not write a file.", "");
+
+ public IntOption periodicityOption = new IntOption("periodicity", 'g',
+ "Number of instances between simplex updates.", 1000, 1, Integer.MAX_VALUE);
+
+ public FloatOption convergenceSphereOption = new FloatOption("convergenceSphere", 'c',
+ "Convergence threshold: squared distance between centroids.", 0.001, 0.0, Double.MAX_VALUE);
+
+ public MultiChoiceOption metricOption = new MultiChoiceOption("metric", 'm',
+ "Metric to optimize the model.", MetricUtils.REGRESSION_METRIC_NAMES,
+ MetricUtils.REGRESSION_METRIC_DESCRIPTIONS, 0);
+
+ public FlagOption resetModelsOption = new FlagOption("resetModels", 'r',
+ "Create fresh classifiers for expanded points instead of warm-starting from best.");
+
+ public FlagOption verboseOption = new FlagOption("verbose", 'v',
+ "Print simplex events to stdout.");
+
+ public FlagOption driftDetectionOption = new FlagOption("driftDetection", 'd',
+ "Enable drift detection on the incumbent's prediction error.");
+
+ public ClassOption driftDetectorOption = new ClassOption("driftDetector", 'D',
+ "Change detector to use when drift detection is enabled; on a detected"
+ + " drift the whole search is reinitialized from the search space.",
+ ChangeDetector.class, "ADWINChangeDetector");
+
+ public IntOption numberOfJobsOption = new IntOption("numberOfJobs", 'j',
+ "Total number of concurrent jobs used for processing (-1 = as much as possible, 0 = do not use multithreading)",
+ 1, -1, Integer.MAX_VALUE);
+
+ protected static final int SINGLE_THREAD = 0;
+
+ /** Largest batch trained per instance: the 3 vertices plus the 6 expansion points. */
+ protected static final int MAX_MODELS_PER_INSTANCE = 9;
+
+ // ========== INNER CLASS ==========
+
+ protected static class SimplexEntry implements Serializable {
+ Classifier model;
+ BasicRegressionPerformanceEvaluator evaluator;
+ ArrayList params;
+ long instancesSeen;
+
+ SimplexEntry(Classifier model, BasicRegressionPerformanceEvaluator evaluator,
+ ArrayList params) {
+ this.model = model;
+ this.evaluator = evaluator;
+ this.params = params;
+ this.instancesSeen = 0;
+ }
+
+ double getMetric(int metricIndex) {
+ if (instancesSeen == 0) return Double.NEGATIVE_INFINITY;
+ return MetricUtils.getRegressionScore(evaluator.getPerformanceMeasurements(), metricIndex);
+ }
+
+ void addResult(InstanceExample example, double[] votes) {
+ evaluator.addResult(example, votes);
+ instancesSeen++;
+ }
+
+ /** Forget what this entry scored, so a fresh window starts even. */
+ void resetEvaluation() {
+ evaluator.reset();
+ instancesSeen = 0;
+ }
+ }
+
+ // ========== FIELDS ==========
+
+ protected SimplexEntry[] simplex;
+ protected HashMap expanded;
+
+ protected ArrayList lastCentroid;
+
+ protected ConfigurationSpace space;
+
+ /** Boundary through which every learner is configured. */
+ protected LearnerConfigurator configurator;
+
+ protected long instanceCount;
+ protected int evaluationInstances;
+ protected boolean converged;
+
+ protected boolean[] optimizeMask;
+
+ /** Detector on the incumbent's error; {@code null} unless drift detection is on. */
+ protected ChangeDetector driftDetector;
+
+ /** Cumulative number of drifts signalled over the run; not reset by a restart. */
+ protected long driftsDetected;
+
+ /**
+ * Pool training the simplex vertices and expansion points concurrently;
+ * {@code null} when running single-threaded. Transient because an executor
+ * cannot be serialized, so it is (re)created by {@link #initExecutor()}.
+ */
+ protected transient ExecutorService executor;
+
+ @Override
+ public void setOptimizableParameters(boolean[] mask) { this.optimizeMask = mask; }
+
+ // ========== LIFECYCLE ==========
+
+ @Override
+ public boolean isRandomizable() {
+ return true;
+ }
+
+ /**
+ * Creates the training pool on first use, and again after deserialization.
+ * A pool larger than the batch trained per instance would leave threads idle,
+ * so the requested job count is capped at {@link #MAX_MODELS_PER_INSTANCE}.
+ */
+ protected void initExecutor() {
+ if (this.executor != null) return;
+ int numberOfJobs = this.numberOfJobsOption.getValue() == -1
+ ? Runtime.getRuntime().availableProcessors()
+ : this.numberOfJobsOption.getValue();
+ numberOfJobs = Math.min(numberOfJobs, MAX_MODELS_PER_INSTANCE);
+ // SINGLE_THREAD and requesting a single thread are equivalent: training
+ // then happens in-place and this.executor stays null.
+ if (numberOfJobs != SINGLE_THREAD && numberOfJobs != 1) {
+ // Daemon threads: a live pool must not keep the JVM alive after the
+ // task that ran this learner has finished.
+ this.executor = Executors.newFixedThreadPool(numberOfJobs, runnable -> {
+ Thread thread = new Thread(runnable);
+ thread.setDaemon(true);
+ return thread;
+ });
+ }
+ }
+
+ @Override
+ public void cleanThreads() {
+ if (this.executor != null) {
+ this.executor.shutdownNow();
+ this.executor = null;
+ }
+ }
+
+ @Override
+ public void resetLearningImpl() {
+ cleanThreads(); // shut down any pool from a previous reset before creating a new one
+ converged = false;
+ instanceCount = 0;
+ evaluationInstances = 0;
+ lastCentroid = null;
+ expanded = null;
+
+ driftDetector = driftDetectionOption.isSet()
+ ? ((ChangeDetector) getPreparedClassOption(driftDetectorOption)).copy()
+ : null;
+
+ setConfigurations();
+ initializeSimplex();
+
+ }
+
+ @Override
+ public void setConfigurations() {
+ try {
+ this.space = ConfigurationSpace.resolve(configurationFileOption.getValue(), searchSpaceOption.getValue());
+ this.configurator = new LearnerConfigurator(this.space);
+ this.configurator.validate();
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not set up " + getClass().getSimpleName()
+ + " from " + ConfigurationSpace.describeSource(configurationFileOption.getValue(), searchSpaceOption.getValue()) + ": " + e.getMessage(), e);
+ }
+ }
+
+
+ @Override
+ public void checkParameterChange() {}
+
+ @Override
+ public void swapClassifiers(int bestPerforming) {}
+
+ @Override
+ public void deepCopyList(int bestPerforming) {}
+
+ @Override
+ public void changeStateParameter(Parameter parameter, int index) {}
+
+ private void initializeSimplex() {
+ simplex = new SimplexEntry[3];
+ for (int i = 0; i < 3; i++) {
+ ArrayList params = createRandomParams();
+ Classifier model = createModelWithParams(params);
+ simplex[i] = new SimplexEntry(model, newEvaluator(), params);
+ }
+ if (verboseOption.isSet())
+ System.out.println("SSPT: Initialized simplex with 3 models");
+ }
+
+ private ArrayList createRandomParams() {
+ ArrayList params = new ArrayList<>();
+ for (ParameterSpec spec : this.space.parameters) {
+ String name = spec.name;
+ switch (spec.type) {
+ case ParameterSpec.TYPE_INT: {
+ int[] range = {(int) spec.range[0], (int) spec.range[1]};
+ int value = classifierRandom.nextInt(range[1] - range[0] + 1) + range[0];
+ params.add(new IntParameter(name, value, range, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case ParameterSpec.TYPE_DOUBLE: {
+ double[] range = {spec.range[0], spec.range[1]};
+ double value = range[0] + classifierRandom.nextDouble() * (range[1] - range[0]);
+ params.add(new DoubleParameter(name, value, range, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case ParameterSpec.TYPE_CATEGORICAL: {
+ String[] values = spec.values;
+ int active = classifierRandom.nextInt(values.length);
+ params.add(new CategoricalParameter(name, values, active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return params;
+ }
+
+ /** A model built from scratch at {@code params}. */
+ private Classifier createModelWithParams(ArrayList params) {
+ return this.configurator.instantiate(params, this.classifierRandom.nextInt());
+ }
+
+ /**
+ * Reconfigure a warm-started model in place, returning the model to use.
+ * Falls back to rebuilding when the space contains a hyperparameter the
+ * learner only reads at construction time, which by definition cannot take
+ * effect on a model that is already training.
+ */
+ private Classifier applyParamsToModel(Classifier model, ArrayList params) {
+ if (!this.configurator.canApplyLive()) {
+ return this.configurator.instantiate(params, this.classifierRandom.nextInt());
+ }
+ this.configurator.applyLive(model, params);
+ return model;
+ }
+
+ private BasicRegressionPerformanceEvaluator newEvaluator() {
+ return new BasicRegressionPerformanceEvaluator();
+ }
+
+ // ========== TRAINING ==========
+
+ @Override
+ public double[] getVotesForInstance(Instance inst) {
+ return simplex[0].model.getVotesForInstance(inst);
+ }
+
+ @Override
+ public void trainOnInstanceImpl(Instance inst) {
+ double[] incumbentVotes = driftDetector != null ? getVotesForInstance(inst) : null;
+ initExecutor();
+ instanceCount++;
+ evaluationInstances++;
+ InstanceExample example = new InstanceExample(inst);
+
+ trainNotConverged(inst, example);
+
+ if (incumbentVotes != null) checkDrift(inst, incumbentVotes);
+ }
+
+ private void trainConverged(Instance inst, InstanceExample example) {
+ SimplexEntry best = simplex[0];
+ double[] votes = best.model.getVotesForInstance(inst);
+ best.addResult(example, votes);
+ best.model.trainOnInstance(inst);
+ }
+
+ private void trainNotConverged(Instance inst, InstanceExample example) {
+
+ // Vertices and expansion points form a single batch: two barriers per
+ // instance would halve the gain on such a small number of models.
+ Collection trainers = this.executor == null
+ ? null : new ArrayList();
+
+ for (SimplexEntry entry : simplex) {
+ double[] votes = entry.model.getVotesForInstance(inst);
+ entry.addResult(example, votes);
+
+ if (trainers == null) entry.model.trainOnInstance(inst);
+ else trainers.add(new TrainingRunnable(entry.model, inst));
+ }
+
+ if (expanded != null) {
+ for (SimplexEntry entry : expanded.values()) {
+ double[] votes = entry.model.getVotesForInstance(inst);
+ entry.addResult(example, votes);
+
+ if (trainers == null) entry.model.trainOnInstance(inst);
+ else trainers.add(new TrainingRunnable(entry.model, inst));
+ }
+ }
+
+ if (trainers != null) {
+ try {
+ this.executor.invokeAll(trainers);
+ } catch (InterruptedException ex) {
+ throw new RuntimeException("Could not call invokeAll() on training threads.");
+ }
+ }
+
+ if (evaluationInstances >= periodicityOption.getValue()) {
+ evaluationInstances = 0;
+ boolean moved = updateSimplex();
+ if (moved && checkConvergence()) {
+ if (verboseOption.isSet())
+ System.out.println("SSPT: Converged at instance " + instanceCount);
+ converged = true;
+ }
+ }
+ }
+
+ // ========== SIMPLEX UPDATE ==========
+
+ /**
+ * Advances the simplex, alternating between laying down the Nelder-Mead
+ * candidate points and choosing among them.
+ *
+ * The two phases must be separate windows. {@link #createExpanded()}
+ * builds the reflection, expansion, contraction, shrink and midpoint
+ * models but trains none of them, and
+ * {@link #applyNelderMeadOperators()} ranks them by measured performance -
+ * so applying the operators in the same call that creates the points
+ * compares six models that have seen no instances at all. Every
+ * comparison then fails, the operators fall through to their last branch,
+ * and the simplex is overwritten with untrained shrink and midpoint models
+ * on every update, whatever the data says.
+ *
+ *
Splitting the phases lets the candidate points train alongside the
+ * vertices for a full window first, which is what makes the choice between
+ * them meaningful.
+ *
+ * @return whether the simplex moved, i.e. whether this call applied
+ * the operators rather than laying the candidate points down
+ */
+ private boolean updateSimplex() {
+ if (expanded == null || expanded.isEmpty()) {
+ sortSimplex();
+ lastCentroid = computeCentroid();
+ expanded = createExpanded();
+ // The vertices have been accumulating since they were created and
+ // the candidate points have seen nothing, so the next window is
+ // scored from a common start for all nine models.
+ for (SimplexEntry vertex : simplex) vertex.resetEvaluation();
+ return false;
+ }
+
+ applyNelderMeadOperators();
+ expanded = null;
+ return true;
+ }
+
+ private void sortSimplex() {
+ int metric = metricOption.getChosenIndex();
+ // Insertion sort (3 elements, descending by metric)
+ for (int i = 1; i < 3; i++) {
+ SimplexEntry key = simplex[i];
+ int j = i - 1;
+ while (j >= 0 && simplex[j].getMetric(metric) < key.getMetric(metric)) {
+ simplex[j + 1] = simplex[j];
+ j--;
+ }
+ simplex[j + 1] = key;
+ }
+ }
+
+ private ArrayList computeCentroid() {
+ ArrayList centroid = cloneParams(simplex[0].params);
+ for (int i = 0; i < centroid.size(); i++) {
+ Parameter p = centroid.get(i);
+ if (p.type == 0) {
+ double sum = 0;
+ for (SimplexEntry e : simplex) sum += ((IntParameter) e.params.get(i)).value;
+ int val = clampInt((int) Math.round(sum / 3.0),
+ ((IntParameter) p).range[0], ((IntParameter) p).range[1]);
+ ((IntParameter) p).value = val;
+ } else if (p.type == 1) {
+ double sum = 0;
+ for (SimplexEntry e : simplex) sum += ((DoubleParameter) e.params.get(i)).value;
+ double val = clampDouble(sum / 3.0,
+ ((DoubleParameter) p).range[0], ((DoubleParameter) p).range[1]);
+ ((DoubleParameter) p).value = val;
+ } else {
+ // Mode for categorical
+ Map counts = new HashMap<>();
+ for (SimplexEntry e : simplex)
+ counts.merge(((CategoricalParameter) e.params.get(i)).active, 1, Integer::sum);
+ int mode = Collections.max(counts.entrySet(), Map.Entry.comparingByValue()).getKey();
+ ((CategoricalParameter) p).active = mode;
+ }
+ }
+ return centroid;
+ }
+
+ private boolean checkConvergence() {
+ if (lastCentroid == null) return false;
+ ArrayList current = computeCentroid();
+ double distSq = 0;
+ for (int i = 0; i < lastCentroid.size(); i++) {
+ Parameter lp = lastCentroid.get(i);
+ Parameter cp = current.get(i);
+ if (lp.type == 0) {
+ double diff = ((IntParameter) lp).value - ((IntParameter) cp).value;
+ distSq += diff * diff;
+ } else if (lp.type == 1) {
+ double diff = ((DoubleParameter) lp).value - ((DoubleParameter) cp).value;
+ distSq += diff * diff;
+ }
+ }
+ double threshold = convergenceSphereOption.getValue();
+ return distSq < threshold * threshold;
+ }
+
+ // ========== NELDER-MEAD ==========
+
+ private interface DoubleOp {
+ double apply(double a, double b);
+ }
+
+ private HashMap createExpanded() {
+ HashMap exp = new HashMap<>();
+
+ ArrayList bestP = simplex[0].params;
+ ArrayList goodP = simplex[1].params;
+ ArrayList worstP = simplex[2].params;
+
+ ArrayList midP = combine(bestP, goodP, (a, b) -> (a + b) / 2.0);
+ ArrayList reflP = combine(midP, worstP, (a, b) -> 2 * a - b);
+ ArrayList expP = combine(reflP, midP, (a, b) -> 2 * a - b);
+ ArrayList shrP = combine(bestP, worstP, (a, b) -> (a + b) / 2.0);
+ ArrayList cont1P = combine(midP, worstP, (a, b) -> (a + b) / 2.0);
+ ArrayList cont2P = combine(midP, reflP, (a, b) -> (a + b) / 2.0);
+
+ String[] names = {"midpoint", "reflection", "expansion", "shrink", "contraction1", "contraction2"};
+ @SuppressWarnings("unchecked")
+ ArrayList[] paramSets = new ArrayList[]{midP, reflP, expP, shrP, cont1P, cont2P};
+
+ // Freeze the hyperparameters outside the optimized subset to the best
+ // simplex vertex before any model is built or evaluated.
+ for (ArrayList ps : paramSets)
+ HPOMethod.freezeToIncumbent(this.optimizeMask, ps, simplex[0].params);
+
+ for (int i = 0; i < names.length; i++) {
+ SimplexEntry entry;
+ if (resetModelsOption.isSet()) {
+ Classifier model = createModelWithParams(paramSets[i]);
+ entry = new SimplexEntry(model, newEvaluator(), paramSets[i]);
+ } else {
+ // Warm-start: copy best model's learned state, change parameters
+ Classifier model = simplex[0].model.copy();
+ LearnerConfigurator.reseedCopy(model, this.classifierRandom.nextInt());
+ model = applyParamsToModel(model, paramSets[i]);
+ entry = new SimplexEntry(model, newEvaluator(), paramSets[i]);
+ }
+ exp.put(names[i], entry);
+ }
+ return exp;
+ }
+
+ private void applyNelderMeadOperators() {
+ int metric = metricOption.getChosenIndex();
+ SimplexEntry b = simplex[0];
+ SimplexEntry g = simplex[1];
+ SimplexEntry w = simplex[2];
+ SimplexEntry r = expanded.get("reflection");
+ SimplexEntry c1 = expanded.get("contraction1");
+ SimplexEntry c2 = expanded.get("contraction2");
+ SimplexEntry e = expanded.get("expansion");
+ SimplexEntry s = expanded.get("shrink");
+ SimplexEntry mid = expanded.get("midpoint");
+
+ SimplexEntry contraction = c1.getMetric(metric) > c2.getMetric(metric) ? c1 : c2;
+
+ if (r.getMetric(metric) > g.getMetric(metric)) {
+ if (b.getMetric(metric) > r.getMetric(metric)) {
+ simplex[2] = r;
+ } else {
+ simplex[2] = e.getMetric(metric) > b.getMetric(metric) ? e : r;
+ }
+ } else {
+ if (r.getMetric(metric) > w.getMetric(metric)) {
+ simplex[2] = r;
+ } else {
+ if (contraction.getMetric(metric) > w.getMetric(metric)) {
+ simplex[2] = contraction;
+ } else {
+ simplex[2] = s;
+ simplex[1] = mid;
+ }
+ }
+ }
+ sortSimplex();
+ }
+
+ // ========== HELPERS ==========
+
+ private ArrayList combine(ArrayList p1, ArrayList p2, DoubleOp op) {
+ ArrayList result = new ArrayList<>();
+ for (int i = 0; i < p1.size(); i++) {
+ Parameter a = p1.get(i);
+ Parameter b = p2.get(i);
+ switch (a.type) {
+ case Parameter.TYPE_INT: {
+ int[] range = ((IntParameter) a).range;
+ double combined = op.apply(((IntParameter) a).value, ((IntParameter) b).value);
+ int val = clampInt((int) Math.round(combined), range[0], range[1]);
+ result.add(new IntParameter(a.name, val, range, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_DOUBLE: {
+ double[] range = ((DoubleParameter) a).range;
+ double val = clampDouble(
+ op.apply(((DoubleParameter) a).value, ((DoubleParameter) b).value),
+ range[0], range[1]);
+ result.add(new DoubleParameter(a.name, val, range, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_CATEGORICAL: {
+ CategoricalParameter ca = (CategoricalParameter) a;
+ CategoricalParameter cb = (CategoricalParameter) b;
+ int active = classifierRandom.nextBoolean() ? ca.active : cb.active;
+ result.add(new CategoricalParameter(a.name, ca.values, active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return result;
+ }
+
+ private ArrayList cloneParams(ArrayList source) {
+ ArrayList copy = new ArrayList<>();
+ for (Parameter p : source) {
+ switch (p.type) {
+ case Parameter.TYPE_INT: {
+ IntParameter ip = (IntParameter) p;
+ copy.add(new IntParameter(ip.name, ip.value, ip.range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_DOUBLE: {
+ DoubleParameter dp = (DoubleParameter) p;
+ copy.add(new DoubleParameter(dp.name, dp.value, dp.range.clone(), new Random(classifierRandom.nextLong())));
+ break;
+ }
+ case Parameter.TYPE_CATEGORICAL: {
+ CategoricalParameter cp = (CategoricalParameter) p;
+ copy.add(new CategoricalParameter(cp.name, cp.values.clone(), cp.active, new Random(classifierRandom.nextLong())));
+ break;
+ }
+ }
+ }
+ return copy;
+ }
+
+ private int clampInt(int val, int min, int max) {
+ return Math.max(min, Math.min(max, val));
+ }
+
+ private double clampDouble(double val, double min, double max) {
+ return Math.max(min, Math.min(max, val));
+ }
+
+ // ========== MOA INTERFACE ==========
+
+ @Override
+ protected Measurement[] getModelMeasurementsImpl() {
+ ArrayList measurements = new ArrayList<>();
+ measurements.add(new Measurement("driftsDetected", driftsDetected));
+ for (Measurement m : simplex[0].model.getModelMeasurements())
+ measurements.add(m);
+ for (Parameter p : simplex[0].params) {
+ switch (p.type) {
+ case Parameter.TYPE_INT:
+ measurements.add(new Measurement(p.name, ((IntParameter) p).value));
+ break;
+ case Parameter.TYPE_DOUBLE:
+ measurements.add(new Measurement(p.name, ((DoubleParameter) p).value));
+ break;
+ case Parameter.TYPE_CATEGORICAL:
+ measurements.add(new Measurement(p.name, ((CategoricalParameter) p).active));
+ break;
+ }
+ }
+ return measurements.toArray(new Measurement[0]);
+ }
+
+ @Override
+ public void getModelDescription(StringBuilder out, int indent) {
+ }
+
+ @Override
+ public long measureByteSize() {
+ long size = SizeOf.sizeOf(this);
+ for (SimplexEntry entry : simplex)
+ size += entry.model.measureByteSize();
+ return size;
+ }
+
+ @Override
+ public double getCandidateScore(int i) {
+ return simplex[i].getMetric(metricOption.getChosenIndex());
+ }
+
+ @Override
+ public double getClassifierScore() {
+ return simplex[0].getMetric(metricOption.getChosenIndex());
+ }
+
+ @Override
+ public int getNumberOfCandidates() { return simplex == null ? 0 : simplex.length; }
+
+ @Override
+ public long getStatesEvaluatedCount() { return instanceCount / periodicityOption.getValue(); }
+
+ @Override
+ public int getEvaluationInstancesCount() { return evaluationInstances; }
+
+ @Override
+ public int getPeriodicity() { return periodicityOption.getValue(); }
+
+ @Override
+ public Classifier getMainClassifier() { return simplex[0].model; }
+
+ @Override
+ public ArrayList getReferenceParameters() { return simplex[0].params; }
+
+ @Override
+ public String getConfigurationFile() { return this.configurationFileOption.getValue();}
+
+
+ @Override
+ public ArrayList> getCandidateParameters() {
+ ArrayList> list = new ArrayList<>();
+ for (SimplexEntry e : simplex) list.add(e.params);
+ return list;
+ }
+
+ /**
+ * Feeds the incumbent's prediction error to the change detector and, when a
+ * drift is signalled, reinitializes the whole search from the search space.
+ * The signal is the absolute error |y - y_pred|.
+ */
+ private void checkDrift(Instance inst, double[] incumbentVotes) {
+ double predicted = incumbentVotes.length > 0 ? incumbentVotes[0] : 0.0;
+ driftDetector.input(Math.abs(predicted - inst.classValue()));
+ if (driftDetector.getChange()) {
+ driftsDetected++;
+ if (verboseOption.isSet())
+ System.out.println("SSPT: Drift detected at instance " + instanceCount
+ + ", reinitializing");
+ resetLearningImpl();
+ }
+ }
+
+ /**
+ * Inner class to assist with the multi-thread execution.
+ */
+ protected class TrainingRunnable implements Runnable, Callable {
+ final private Classifier learner;
+ final private Instance instance;
+
+ public TrainingRunnable(Classifier learner, Instance instance) {
+ this.learner = learner;
+ this.instance = instance;
+ }
+
+ @Override
+ public void run() {
+ this.learner.trainOnInstance(this.instance);
+ }
+
+ @Override
+ public Integer call() {
+ run();
+ return 0;
+ }
+ }
+}
diff --git a/moa/src/main/java/moa/classifiers/AutoML/space/ConfigurationSpace.java b/moa/src/main/java/moa/classifiers/AutoML/space/ConfigurationSpace.java
new file mode 100644
index 000000000..7998118c8
--- /dev/null
+++ b/moa/src/main/java/moa/classifiers/AutoML/space/ConfigurationSpace.java
@@ -0,0 +1,248 @@
+/*
+ * ConfigurationSpace.java
+ * Copyright (C) 2026 University of Waikato, Hamilton, New Zealand
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package moa.classifiers.AutoML.space;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.Serializable;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The search space shared by every AutoML method: the CLI string of the learner
+ * being tuned, plus the list of tunable hyperparameters.
+ *
+ * Expected JSON:
+ *
+ *
+ * {
+ * "algorithm": "moa.classifiers.trees.HoeffdingTree",
+ * "parameters": [
+ * {"parameter": "gracePeriod", "type": "integer", "value": 200, "range": [50, 500], "step": 10},
+ * {"parameter": "splitConfidence","type": "double", "value": 1E-7, "range": [1E-7, 0.05]},
+ * {"parameter": "splitCriterion", "type": "categorical", "active": 0,
+ * "values": ["moa.classifiers.core.splitcriteria.InfoGainSplitCriterion",
+ * "moa.classifiers.core.splitcriteria.GiniSplitCriterion"]}
+ * ]
+ * }
+ *
+ *
+ * {@code "parameter"} is an option path resolved against the learner's MOA
+ * options - see {@link ParameterSpec}. {@code "type"} accepts {@code integer} /
+ * {@code int}, {@code double} / {@code float} / {@code real}, and
+ * {@code categorical} / {@code nominal}. The optional {@code "onChange"} key
+ * takes {@code "live"} (default) or {@code "reset"}.
+ *
+ * @author Daniel Nowak Assis (daniel dot nowak-assis at lip6 dot fr)
+ */
+public class ConfigurationSpace implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** CLI string of the learner to tune, e.g. {@code "moa.classifiers.trees.HoeffdingTree -g 50"}. */
+ public final String algorithm;
+
+ /** Tunable hyperparameters, in declaration order. */
+ public final List parameters;
+
+ public ConfigurationSpace(String algorithm, List parameters) {
+ this.algorithm = algorithm;
+ this.parameters = parameters;
+ }
+
+ /**
+ * The search space a tuner should use, given both of the ways one can be
+ * supplied: inline JSON wins over a file when set, so that a caller holding
+ * the space in memory - a script, say - never has to write a temporary file
+ * just to satisfy the {@code -f} option.
+ *
+ * @param path path from the {@code configurationFile} option, may be null
+ * @param json JSON from the {@code searchSpace} option, may be null or empty
+ */
+ public static ConfigurationSpace resolve(String path, String json) throws IOException {
+ if (json != null && !json.trim().isEmpty()) {
+ return fromString(json);
+ }
+ return fromFile(path);
+ }
+
+ /**
+ * How to refer to the search space in an error message, matching whichever
+ * of the two options {@link #resolve} would have read.
+ */
+ public static String describeSource(String path, String json) {
+ if (json != null && !json.trim().isEmpty()) return "the inline search space";
+ if (path == null || path.trim().isEmpty()) return "(no search space given)";
+ return "\"" + path + "\"";
+ }
+
+ public static ConfigurationSpace fromFile(String path) throws IOException {
+ if (path == null || path.trim().isEmpty()) {
+ throw new IOException("No search space given. Set -f to a search space JSON file,"
+ + " or -s to the search space JSON itself.");
+ }
+ File file = new File(path);
+ if (!file.isFile()) {
+ throw new IOException("Configuration file not found: " + file.getAbsolutePath());
+ }
+ try (Reader reader = new FileReader(file)) {
+ return read(reader, file.getAbsolutePath());
+ }
+ }
+
+ public static ConfigurationSpace fromString(String json) throws IOException {
+ return read(new StringReader(json), "");
+ }
+
+ private static ConfigurationSpace read(Reader reader, String origin) throws IOException {
+ JsonElement rootElement = JsonParser.parseReader(reader);
+ if (rootElement == null || !rootElement.isJsonObject()) {
+ throw new IOException("Configuration in " + origin + " is not a JSON object.");
+ }
+ JsonObject root = rootElement.getAsJsonObject();
+
+ if (!root.has("algorithm")) {
+ throw new IOException("Configuration in " + origin + " has no \"algorithm\" entry.");
+ }
+ String algorithm = root.get("algorithm").getAsString();
+
+ List specs = new ArrayList<>();
+ if (root.has("parameters")) {
+ JsonArray array = root.getAsJsonArray("parameters");
+ for (int i = 0; i < array.size(); i++) {
+ specs.add(readParameter(array.get(i).getAsJsonObject(), origin, i));
+ }
+ }
+ return new ConfigurationSpace(algorithm, specs);
+ }
+
+ private static ParameterSpec readParameter(JsonObject node, String origin, int index) throws IOException {
+ ParameterSpec spec = new ParameterSpec();
+
+ if (!node.has("parameter")) {
+ throw new IOException("Parameter " + index + " in " + origin + " has no \"parameter\" name.");
+ }
+ spec.name = node.get("parameter").getAsString();
+
+ String type = node.has("type") ? node.get("type").getAsString().toLowerCase() : "";
+ switch (type) {
+ case "integer":
+ case "int":
+ spec.type = ParameterSpec.TYPE_INT;
+ break;
+ case "double":
+ case "float":
+ case "real":
+ spec.type = ParameterSpec.TYPE_DOUBLE;
+ break;
+ case "categorical":
+ case "nominal":
+ spec.type = ParameterSpec.TYPE_CATEGORICAL;
+ break;
+ default:
+ throw new IOException("Parameter \"" + spec.name + "\" in " + origin
+ + " has unknown type \"" + type + "\".");
+ }
+
+ if (spec.type == ParameterSpec.TYPE_CATEGORICAL) {
+ if (!node.has("values")) {
+ throw new IOException("Categorical parameter \"" + spec.name + "\" in " + origin
+ + " has no \"values\" list.");
+ }
+ JsonArray values = node.getAsJsonArray("values");
+ spec.values = new String[values.size()];
+ for (int i = 0; i < values.size(); i++) {
+ spec.values[i] = values.get(i).getAsString();
+ }
+ spec.active = node.has("active") ? node.get("active").getAsInt() : 0;
+ if (spec.active < 0 || spec.active >= spec.values.length) {
+ throw new IOException("Categorical parameter \"" + spec.name + "\" in " + origin
+ + " has \"active\" outside its \"values\" list.");
+ }
+ } else {
+ if (!node.has("range")) {
+ throw new IOException("Numeric parameter \"" + spec.name + "\" in " + origin
+ + " has no \"range\".");
+ }
+ JsonArray range = node.getAsJsonArray("range");
+ if (range.size() != 2) {
+ throw new IOException("Numeric parameter \"" + spec.name + "\" in " + origin
+ + " needs a \"range\" of exactly two entries.");
+ }
+ spec.range = new double[]{range.get(0).getAsDouble(), range.get(1).getAsDouble()};
+ if (spec.range[0] > spec.range[1]) {
+ throw new IOException("Numeric parameter \"" + spec.name + "\" in " + origin
+ + " has an inverted \"range\".");
+ }
+ spec.value = node.has("value") ? node.get("value").getAsDouble() : spec.range[0];
+ if (node.has("step")) {
+ spec.step = node.get("step").getAsDouble();
+ }
+ }
+
+ if (node.has("onChange")) {
+ String onChange = node.get("onChange").getAsString().toLowerCase();
+ switch (onChange) {
+ case "live":
+ spec.live = true;
+ break;
+ case "reset":
+ spec.live = false;
+ break;
+ default:
+ throw new IOException("Parameter \"" + spec.name + "\" in " + origin
+ + " has unknown \"onChange\" value \"" + onChange + "\"; expected live or reset.");
+ }
+ }
+
+ return spec;
+ }
+
+ /** Number of {@code integer} and {@code double} parameters, i.e. the surrogate feature count. */
+ public int numericalCount() {
+ int count = 0;
+ for (ParameterSpec spec : this.parameters) {
+ if (spec.type != ParameterSpec.TYPE_CATEGORICAL) count++;
+ }
+ return count;
+ }
+
+ /** Whether every hyperparameter can be pushed into an already-trained learner. */
+ public boolean allLive() {
+ for (ParameterSpec spec : this.parameters) {
+ if (!spec.live) return false;
+ }
+ return true;
+ }
+
+ public int size() {
+ return this.parameters.size();
+ }
+
+ public ParameterSpec get(int index) {
+ return this.parameters.get(index);
+ }
+}
diff --git a/moa/src/main/java/moa/classifiers/AutoML/space/LearnerAccess.java b/moa/src/main/java/moa/classifiers/AutoML/space/LearnerAccess.java
new file mode 100644
index 000000000..655da1698
--- /dev/null
+++ b/moa/src/main/java/moa/classifiers/AutoML/space/LearnerAccess.java
@@ -0,0 +1,157 @@
+/*
+ * LearnerAccess.java
+ * Copyright (C) 2026 University of Waikato, Hamilton, New Zealand
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see