diff --git a/moa/src/main/java/moa/learners/featureanalysis/ClassifierWithFeatureImportance.java b/moa/src/main/java/moa/learners/featureanalysis/ClassifierWithFeatureImportance.java index e62b11344..f7d2696a2 100644 --- a/moa/src/main/java/moa/learners/featureanalysis/ClassifierWithFeatureImportance.java +++ b/moa/src/main/java/moa/learners/featureanalysis/ClassifierWithFeatureImportance.java @@ -25,6 +25,7 @@ import com.yahoo.labs.samoa.instances.Instance; import moa.classifiers.AbstractClassifier; import moa.classifiers.MultiClassClassifier; +import moa.classifiers.Regressor; import moa.classifiers.meta.AdaptiveRandomForest; import moa.core.Measurement; import moa.core.Utils; @@ -64,7 +65,7 @@ public String getPurposeString() { public ClassOption featureImportanceLearnerOption = new ClassOption("featureImportanceLearner", 'l', "Learner used to build the model from which the feature importances are extracted", - FeatureImportanceClassifier.class, "moa.learners.featureanalysis.FeatureImportanceHoeffdingTree"); + FeatureImportanceLearner.class, "moa.learners.featureanalysis.FeatureImportanceHoeffdingTree"); public FlagOption doNotNormalizeFeatureScoreOption = new FlagOption("doNotNormalizeFeatureScore", 'n', "If set the feature importances will not be normalized"); @@ -84,7 +85,7 @@ public String getPurposeString() { protected PrintStream debugStream; protected long instancesSeen = 0; - protected FeatureImportanceClassifier featureImportanceClassifierLearner; + protected FeatureImportanceLearner featureImportanceClassifierLearner; protected double mean = -1.0; protected double median = -1.0; @@ -113,7 +114,14 @@ protected void createDebugOutputFile() { public void resetLearningImpl() { this.instancesSeen = 0; this.featureImportanceClassifierLearner = null; - this.featureImportanceClassifierLearner = (FeatureImportanceClassifier) getPreparedClassOption(this.featureImportanceLearnerOption); + this.featureImportanceClassifierLearner = (FeatureImportanceLearner) getPreparedClassOption(this.featureImportanceLearnerOption); + // FeatureImportanceLearner covers regressors as well, so they reach the option chooser + // here even though this wrapper reads the votes as a class distribution. + if (this.featureImportanceClassifierLearner instanceof Regressor) { + throw new IllegalArgumentException(this.getClass().getName() + " needs a classifier, " + + "but " + this.featureImportanceClassifierLearner.getClass().getName() + + " is a regressor."); + } this.featureImportanceClassifierLearner.resetLearning(); this.createDebugOutputFile(); } diff --git a/moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceHoeffdingTree.java b/moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceHoeffdingTree.java index 27c2ffe5d..f6060d48b 100644 --- a/moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceHoeffdingTree.java +++ b/moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceHoeffdingTree.java @@ -32,7 +32,7 @@ * @version $Revision: 1 $ */ public class FeatureImportanceHoeffdingTree extends AbstractClassifier implements MultiClassClassifier, - CapabilitiesHandler, FeatureImportanceClassifier { + CapabilitiesHandler, FeatureImportanceLearner { public ClassOption treeLearnerOption = new ClassOption("treeLearner", 'l', "Decision Tree learner.", HoeffdingTree.class, diff --git a/moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceHoeffdingTreeEnsemble.java b/moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceHoeffdingTreeEnsemble.java index 4f66b8a88..66e6776bb 100644 --- a/moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceHoeffdingTreeEnsemble.java +++ b/moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceHoeffdingTreeEnsemble.java @@ -32,7 +32,7 @@ * @version $Revision: 1 $ */ public class FeatureImportanceHoeffdingTreeEnsemble extends AbstractClassifier implements MultiClassClassifier, - CapabilitiesHandler, FeatureImportanceClassifier { + CapabilitiesHandler, FeatureImportanceLearner { public ClassOption ensembleLearnerOption = new ClassOption("ensembleLearner", 'l', "Ensemble learner to train and analyze.", Classifier.class, diff --git a/moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceClassifier.java b/moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceLearner.java similarity index 56% rename from moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceClassifier.java rename to moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceLearner.java index fbcae776d..1de4ea2c5 100644 --- a/moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceClassifier.java +++ b/moa/src/main/java/moa/learners/featureanalysis/FeatureImportanceLearner.java @@ -1,7 +1,6 @@ /* - * FeatureScore.java - * Copyright (C) 2020 University of Waikato, Hamilton, New Zealand - * @author Heitor Murilo Gomes (hgomes at waikato dot ac dot nz) + * FeatureImportanceLearner.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 @@ -22,34 +21,37 @@ import moa.classifiers.Classifier; /** - * Feature Importance Classifier + * Feature Importance Learner * - *
This interface defines the methods to be implemented on a Classifier to allow it to produce feature importances. - *
+ *This interface defines the methods to be implemented on a learner to allow it to produce + * feature importances. Nothing here depends on the kind of prediction being made, so it covers + * classifiers and regressors alike; a regressor additionally declares + * {@link moa.classifiers.Regressor} so that the regression tasks and the regression tab of the + * GUI pick it up.
* - *See details in:
Heitor Murilo Gomes, Rodrigo Fernandes de Mello, Bernhard Pfahringer, Albert Bifet.
- * Feature Scoring using Tree-Based Ensembles for Evolving Data Streams.
+ *
See details in:
Heitor Murilo Gomes, Rodrigo Fernandes de Mello, Bernhard Pfahringer,
+ * Albert Bifet. Feature Scoring using Tree-Based Ensembles for Evolving Data Streams.
* IEEE International Conference on Big Data (pp. 761-769), 2019
A meta classifier that trains any base classifier and, at the same time, incrementally + * estimates the permutation feature importance of every input feature. For each incoming + * instance the loss of the base learner is measured on the original instance and on + * {@code nInteractions} perturbed copies, where the value of a single feature is replaced by + * the value observed for that feature in a randomly drawn instance from a sliding window of + * recent instances (sampling-based imputation). The difference between the perturbed loss and + * the original loss is the importance contribution of that feature, and it is aggregated over + * time with an exponentially weighted moving average controlled by {@code smoothingAlpha}.
+ * + *Importances can be tracked globally (one score per feature) or class-wise (one score per + * feature per class), which is useful under class imbalance. Predictions are simply delegated + * to the base learner, so wrapping a classifier with this class does not change its + * predictive behaviour, only its cost.
+ * + *See details in:
Fabian Fumagalli, Maximilian Muschalik, Eyke Hüllermeier,
+ * Barbara Hammer. Incremental Permutation Feature Importance (iPFI): Towards Online
+ * Explanations on Data Streams. Machine Learning, 2023.
Parameters:
Note that only differences of this quantity are ever used, so a loss is defined up to + * an additive constant. That is why the two accuracy losses score a correct prediction as + * negative and a wrong one as zero rather than the other way round: the importances are + * identical either way.
+ */ + public double calculateLoss(Instance inst, double[] votes) { + int trueClass = (int) inst.classValue(); + if (trueClass < 0 || trueClass >= votes.length) { + return 0.0; + } + switch (this.lossFunctionOption.getChosenIndex()) { + case LOSS_CROSS_ENTROPY: + return -Math.log(clipProbability(votes[trueClass])); + + case LOSS_FOCAL: { + double p = clipProbability(votes[trueClass]); + return -Math.pow(1 - p, this.focalGammaOption.getValue()) * Math.log(p); + } + case LOSS_ACCURACY: + return Utils.maxIndex(votes) == trueClass ? -1.0 : 0.0; + + case LOSS_BRIER: { + double sum = 0.0; + for (int k = 0; k < votes.length; k++) { + double residual = votes[k] - (k == trueClass ? 1.0 : 0.0); + sum += residual * residual; + } + return sum; + } + case LOSS_HINGE: { + double bestCompetitor = 0.0; + for (int k = 0; k < votes.length; k++) { + if (k != trueClass && votes[k] > bestCompetitor) { + bestCompetitor = votes[k]; + } + } + return Math.max(0.0, 1.0 - (votes[trueClass] - bestCompetitor)); + } + case LOSS_BALANCED_ACCURACY: + return Utils.maxIndex(votes) == trueClass ? -classWeight(trueClass) : 0.0; + + default: + return 0.0; + } + } + + /** + * Clamp a probability into the range where the logarithm is finite and negative. + * + *Without this a vote of zero for the true class -- the worst prediction there is -- + * would produce an infinite loss, and the previous workaround of returning 0.0 in that case + * made it score the same as a perfect prediction. That inverted the ranking: a perturbation + * that drove the true-class probability to zero registered as no importance at all.
+ */ + protected static double clipProbability(double p) { + if (Double.isNaN(p)) { + return PROBABILITY_FLOOR; + } + return Math.min(1.0, Math.max(PROBABILITY_FLOOR, p)); + } + + /** Record one more observation of {@code classIndex} for the balanced accuracy loss. */ + protected void countClass(int classIndex) { + if (this.classCounts == null || classIndex < 0 || classIndex >= this.classCounts.length) { + return; + } + if (this.classCounts[classIndex] == 0.0) { + this.observedClasses++; + } + this.classCounts[classIndex]++; + this.classCountTotal++; + } + + /** + * Weight of {@code classIndex} under the balanced accuracy loss: the mean count over the + * classes observed so far divided by this class's own count. A class of average frequency + * weighs 1, a rare class weighs more, so that a minority class is not drowned out of the + * importance estimates by the majority one. + */ + protected double classWeight(int classIndex) { + if (this.classCounts == null || classIndex < 0 || classIndex >= this.classCounts.length + || this.observedClasses == 0 || this.classCounts[classIndex] <= 0.0) { + return 1.0; + } + return (this.classCountTotal / this.observedClasses) / this.classCounts[classIndex]; + } + + /** + * Turn a raw vote array into a distribution over exactly {@code numClasses} entries. + * + *Two things are repaired here. First, learners report votes as the class distribution + * they have observed so far, which is truncated after the highest class index they have + * actually seen: a leaf that is pure for class 0 returns an array of length one. The + * missing classes have a vote of zero, so the array is padded rather than discarded. + * Discarding it would bias the estimates for exactly those features whose perturbation + * sends the instance into a pure leaf, which are the informative ones. Second, unlike + * {@link Utils#normalize(double[])} this does not throw when the learner abstains with an + * all-zero vector, which happens routinely at the start of a stream; the resulting flat + * scores cancel out between the original and the perturbed prediction, so an uninformed + * learner contributes nothing instead of noise.
+ */ + protected double[] toDistribution(double[] votes, int numClasses) { + double[] distribution = new double[Math.max(numClasses, 0)]; + if (votes == null) { + return distribution; + } + double sum = 0.0; + for (int i = 0; i < votes.length && i < distribution.length; i++) { + distribution[i] = votes[i]; + sum += votes[i]; + } + // A NaN sum fails the first test already, so only infinity needs ruling out explicitly. + if (sum > 0.0 && !Double.isInfinite(sum)) { + for (int i = 0; i < distribution.length; i++) { + distribution[i] /= sum; + } + } + return distribution; + } + + /** Allocate the importance matrix and the feature index mapping on the first opportunity. */ + protected void initTracker(int numClasses, int numAttributes, int classIndex) { + if (this.importanceTracker != null) { + return; + } + int numFeatures = numAttributes - 1; + if (numFeatures <= 0) { + return; + } + this.featureIndices = new int[numFeatures]; + int next = 0; + for (int i = 0; i < numAttributes && next < numFeatures; i++) { + if (i != classIndex) { + this.featureIndices[next++] = i; + } + } + int numRows = this.deactivateClasswiseImportanceOption.isSet() ? 1 : Math.max(1, numClasses); + this.importanceTracker = new double[numRows][numFeatures]; + this.classCounts = new double[Math.max(1, numClasses)]; + } + + /** + * The current importance estimates, one value per feature. When class-wise tracking is + * enabled the per-class estimates are averaged. + * + *Note that permutation importances are signed: a feature the model does not use can end + * up slightly negative. Normalization therefore divides by the sum of the absolute values + * rather than by the plain sum, which keeps the sign and bounds the scores to [-1, 1].
+ */ + @Override + public double[] getFeatureImportances(boolean normalize) { + if (this.importanceTracker == null) { + return null; + } + double[] importances = new double[this.importanceTracker[0].length]; + for (int i = 0; i < importances.length; i++) { + double sum = 0.0; + for (int c = 0; c < this.importanceTracker.length; c++) { + sum += this.importanceTracker[c][i]; + } + importances[i] = sum / this.importanceTracker.length; + } + + if (normalize) { + double absSum = 0.0; + for (double importance : importances) { + absSum += Math.abs(importance); + } + if (absSum > 0.0) { + for (int i = 0; i < importances.length; i++) { + importances[i] /= absSum; + } + } + } + return importances; + } + + @Override + public int[] getTopKFeatures(int k, boolean normalize) { + double[] importances = getFeatureImportances(normalize); + if (importances == null) { + return null; + } + if (k > importances.length) { + k = importances.length; + } + + double[] remaining = importances.clone(); + int[] topK = new int[k]; + for (int i = 0; i < k; i++) { + int currentTop = Utils.maxIndex(remaining); + topK[i] = currentTop; + remaining[currentTop] = Double.NEGATIVE_INFINITY; + } + return topK; + } + + /** + * The raw importance matrix, indexed by [class][feature] (a single row when class-wise + * tracking is disabled). Returns null before the first instance is seen. + */ + public double[][] getImportanceMatrix() { + return this.importanceTracker; + } + + @Override + public Classifier[] getSubClassifiers() { + return this.classifier == null ? null : new Classifier[]{this.classifier}; + } + + /** + * One measurement per tracked importance value, named after the attribute it belongs to + * (and, when class-wise tracking is on, after the class label) instead of by raw index. + * + *The measurements and their order have to stay identical across calls, since MOA + * derives the CSV header from the first invocation only. Hence the names come from the + * model context, which is fixed for the run, and the values are never reordered by rank.
+ */ + @Override + public Measurement[] getModelMeasurementsImpl() { + if (this.importanceTracker == null || this.importanceTracker[0].length == 0) { + return new Measurement[0]; + } + + int numRows = this.importanceTracker.length; + int numFeatures = this.importanceTracker[0].length; + boolean classwise = numRows > 1; + + Measurement[] measurements = new Measurement[numRows * numFeatures]; + int next = 0; + for (int c = 0; c < numRows; c++) { + for (int f = 0; f < numFeatures; f++) { + String name = classwise + ? "importance(" + featureName(f) + " | " + classLabel(c) + ")" + : "importance(" + featureName(f) + ")"; + measurements[next++] = new Measurement(name, this.importanceTracker[c][f]); + } + } + return measurements; + } + + /** Display name of tracked feature {@code i}: its attribute name when known, else its index. */ + protected String featureName(int i) { + InstancesHeader context = this.getModelContext(); + if (context != null && this.featureIndices != null && i < this.featureIndices.length + && this.featureIndices[i] < context.numAttributes()) { + return displayable(context.attribute(this.featureIndices[i]).name(), "att" + i); + } + return "att" + i; + } + + /** Display name of class {@code c}: its class label when known, else its index. */ + protected String classLabel(int c) { + InstancesHeader context = this.getModelContext(); + if (context != null && context.classAttribute() != null + && context.classAttribute().isNominal() && c < context.numClasses()) { + return displayable(context.classAttribute().value(c), "class" + c); + } + return "class" + c; + } + + /** Measurement names end up as CSV column headers, so keep the separators out of them. */ + private static String displayable(String name, String fallback) { + if (name == null || name.trim().isEmpty()) { + return fallback; + } + return name.trim().replaceAll("[,\"\r\n]", "_"); + } + + @Override + public void getModelDescription(StringBuilder out, int indent) { + } + + @Override + public boolean isRandomizable() { + return true; + } +} diff --git a/moa/src/main/java/moa/learners/featureanalysis/iPFIRegressor.java b/moa/src/main/java/moa/learners/featureanalysis/iPFIRegressor.java new file mode 100644 index 000000000..af14c775a --- /dev/null +++ b/moa/src/main/java/moa/learners/featureanalysis/iPFIRegressor.java @@ -0,0 +1,434 @@ +/* + * iPFIRegressor.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, seeThe regression counterpart of {@link iPFIClassifier}. A meta regressor that trains any base + * regressor and, at the same time, incrementally estimates the permutation feature importance of + * every input feature. For each incoming instance the error of the base learner is measured on + * the original instance and on {@code nInteractions} perturbed copies, where the value of a + * single feature is replaced by the value observed for that feature in a randomly drawn instance + * from a sliding window of recent instances (sampling-based imputation). The difference between + * the perturbed error and the original error is the importance contribution of that feature, and + * it is aggregated over time with an exponentially weighted moving average controlled by + * {@code smoothingAlpha}.
+ * + *Unlike the classification case there is no class to condition on, so a single importance + * value is tracked per feature. Predictions are simply delegated to the base learner, so + * wrapping a regressor with this class does not change its predictive behaviour, only its + * cost.
+ * + *Importances are expressed in the units of the loss, and therefore in the units of the + * target. That makes them incomparable across streams with different target scales, and it lets + * a single outlier dominate the average for a long time under squared error. Absolute error is + * the default for that reason, and {@code -z} additionally divides every error by a running + * estimate of the standard deviation of the target, which makes the scores scale-free.
+ * + *See details in:
Fabian Fumagalli, Maximilian Muschalik, Eyke Hüllermeier,
+ * Barbara Hammer. Incremental Permutation Feature Importance (iPFI): Towards Online
+ * Explanations on Data Streams. Machine Learning, 2023.
Parameters:
Note that permutation importances are signed: a feature the model does not use can end + * up slightly negative. Normalization therefore divides by the sum of the absolute values + * rather than by the plain sum, which keeps the sign and bounds the scores to [-1, 1].
+ */ + @Override + public double[] getFeatureImportances(boolean normalize) { + if (this.importanceTracker == null) { + return null; + } + double[] importances = this.importanceTracker.clone(); + + if (normalize) { + double absSum = 0.0; + for (double importance : importances) { + absSum += Math.abs(importance); + } + if (absSum > 0.0) { + for (int i = 0; i < importances.length; i++) { + importances[i] /= absSum; + } + } + } + return importances; + } + + @Override + public int[] getTopKFeatures(int k, boolean normalize) { + double[] importances = getFeatureImportances(normalize); + if (importances == null) { + return null; + } + if (k > importances.length) { + k = importances.length; + } + + double[] remaining = importances.clone(); + int[] topK = new int[k]; + for (int i = 0; i < k; i++) { + int currentTop = Utils.maxIndex(remaining); + topK[i] = currentTop; + remaining[currentTop] = Double.NEGATIVE_INFINITY; + } + return topK; + } + + /** The raw importance estimates. Returns null before the first instance is seen. */ + public double[] getImportanceVector() { + return this.importanceTracker; + } + + @Override + public Classifier[] getSubClassifiers() { + return this.regressor == null ? null : new Classifier[]{this.regressor}; + } + + /** + * One measurement per feature, named after the attribute it belongs to instead of by raw + * index. + * + *The measurements and their order have to stay identical across calls, since MOA + * derives the CSV header from the first invocation only. Hence the names come from the + * model context, which is fixed for the run, and the values are never reordered by rank.
+ */ + @Override + public Measurement[] getModelMeasurementsImpl() { + if (this.importanceTracker == null || this.importanceTracker.length == 0) { + return new Measurement[0]; + } + + Measurement[] measurements = new Measurement[this.importanceTracker.length]; + for (int f = 0; f < this.importanceTracker.length; f++) { + measurements[f] = new Measurement("importance(" + featureName(f) + ")", + this.importanceTracker[f]); + } + return measurements; + } + + /** Display name of tracked feature {@code i}: its attribute name when known, else its index. */ + protected String featureName(int i) { + InstancesHeader context = this.getModelContext(); + if (context != null && this.featureIndices != null && i < this.featureIndices.length + && this.featureIndices[i] < context.numAttributes()) { + return displayable(context.attribute(this.featureIndices[i]).name(), "att" + i); + } + return "att" + i; + } + + /** Measurement names end up as CSV column headers, so keep the separators out of them. */ + private static String displayable(String name, String fallback) { + if (name == null || name.trim().isEmpty()) { + return fallback; + } + return name.trim().replaceAll("[,\"\r\n]", "_"); + } + + @Override + public void getModelDescription(StringBuilder out, int indent) { + } + + @Override + public boolean isRandomizable() { + return true; + } +}