FeatureEvaluationService.java
package org.hammer.audio.experimental.acoustic.feature.evaluation;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.ToDoubleFunction;
import org.hammer.audio.experimental.acoustic.dataset.FeatureHistogram;
import org.hammer.audio.experimental.acoustic.wingbeat.WingbeatFeatureVector;
/**
* Computes a {@link FeatureEvaluationReport} from a labelled collection of {@link
* WingbeatFeatureVector}s.
*
* <p>Each scalar feature field of {@link WingbeatFeatureVector} becomes one {@link
* FeatureEvaluationEntry}. List-valued fields ({@code harmonicAmplitudes} and {@code
* harmonicRatios}) contribute their first element as a scalar feature, or {@code 0} when the list
* is empty.
*
* <p>This service is stateless; the same instance may be called concurrently.
*/
public final class FeatureEvaluationService {
/** Ordered list of (featureName, extractor) pairs covering all scalar-like fields. */
private static final List<Map.Entry<String, ToDoubleFunction<WingbeatFeatureVector>>> EXTRACTORS =
buildExtractors();
private static List<Map.Entry<String, ToDoubleFunction<WingbeatFeatureVector>>>
buildExtractors() {
List<Map.Entry<String, ToDoubleFunction<WingbeatFeatureVector>>> list = new ArrayList<>();
list.add(Map.entry("fundamentalFrequencyHz", WingbeatFeatureVector::fundamentalFrequencyHz));
list.add(
Map.entry(
"harmonicAmplitude1",
v -> v.harmonicAmplitudes().isEmpty() ? 0.0 : v.harmonicAmplitudes().get(0)));
list.add(
Map.entry(
"harmonicRatio1", v -> v.harmonicRatios().isEmpty() ? 0.0 : v.harmonicRatios().get(0)));
list.add(Map.entry("spectralCentroidHz", WingbeatFeatureVector::spectralCentroidHz));
list.add(Map.entry("spectralBandwidthHz", WingbeatFeatureVector::spectralBandwidthHz));
list.add(
Map.entry("frequencyDriftHzPerSecond", WingbeatFeatureVector::frequencyDriftHzPerSecond));
list.add(Map.entry("frequencyJitterHz", WingbeatFeatureVector::frequencyJitterHz));
list.add(Map.entry("amplitudeModulation", WingbeatFeatureVector::amplitudeModulation));
list.add(Map.entry("signalToNoiseRatio", WingbeatFeatureVector::signalToNoiseRatio));
list.add(Map.entry("trackDurationSeconds", WingbeatFeatureVector::trackDurationSeconds));
list.add(Map.entry("featureConfidence", WingbeatFeatureVector::featureConfidence));
return List.copyOf(list);
}
/**
* Evaluate every feature in the given labelled dataset.
*
* @param vectors feature vectors; must not be {@code null} or empty
* @param labels class labels, one per vector; must not be {@code null}; must have the same size
* as {@code vectors}
* @return feature evaluation report; never {@code null}
*/
public FeatureEvaluationReport evaluate(
List<WingbeatFeatureVector> vectors, List<String> labels) {
Objects.requireNonNull(vectors, "vectors");
Objects.requireNonNull(labels, "labels");
if (vectors.isEmpty()) {
throw new IllegalArgumentException("vectors must not be empty");
}
if (vectors.size() != labels.size()) {
throw new IllegalArgumentException("vectors and labels must have the same size");
}
List<FeatureEvaluationEntry> entries = new ArrayList<>(EXTRACTORS.size());
for (Map.Entry<String, ToDoubleFunction<WingbeatFeatureVector>> fe : EXTRACTORS) {
entries.add(evaluateFeature(fe.getKey(), fe.getValue(), vectors, labels));
}
return new FeatureEvaluationReport(entries);
}
/**
* Return the ordered list of feature names that this service evaluates.
*
* @return feature names in evaluation order; never {@code null}
*/
public static List<String> featureNames() {
List<String> names = new ArrayList<>(EXTRACTORS.size());
for (Map.Entry<String, ToDoubleFunction<WingbeatFeatureVector>> fe : EXTRACTORS) {
names.add(fe.getKey());
}
return List.copyOf(names);
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
private static FeatureEvaluationEntry evaluateFeature(
String name,
ToDoubleFunction<WingbeatFeatureVector> extractor,
List<WingbeatFeatureVector> vectors,
List<String> labels) {
int n = vectors.size();
double[] values = new double[n];
for (int i = 0; i < n; i++) {
values[i] = extractor.applyAsDouble(vectors.get(i));
}
FeatureStatistics stats = computeStatistics(name, values);
ClassSeparationScore separation = computeSeparation(name, values, labels);
double correlation = computeLabelCorrelation(values, labels);
return new FeatureEvaluationEntry(name, stats, separation, correlation);
}
private static FeatureStatistics computeStatistics(String name, double[] values) {
double sum = 0.0;
int missingCount = 0;
int observedCount = 0;
List<Double> observedValues = new ArrayList<>();
for (double v : values) {
if (!Double.isFinite(v)) {
missingCount++;
continue;
}
sum += v;
observedCount++;
observedValues.add(v);
}
if (observedCount == 0) {
return new FeatureStatistics(
name, 0.0, 0.0, FeatureHistogram.of(name, new double[0]), missingCount);
}
double mean = sum / observedCount;
double variance = 0.0;
for (double v : observedValues) {
double diff = v - mean;
variance += diff * diff;
}
double stdDev = Math.sqrt(variance / observedCount);
double[] observedArray = new double[observedValues.size()];
for (int i = 0; i < observedValues.size(); i++) {
observedArray[i] = observedValues.get(i);
}
FeatureHistogram histogram = FeatureHistogram.of(name, observedArray);
return new FeatureStatistics(name, mean, stdDev, histogram, missingCount);
}
@SuppressWarnings("PMD.UseConcurrentHashMap")
private static ClassSeparationScore computeSeparation(
String name, double[] values, List<String> labels) {
// Collect per-class sums and sum-of-squares
Map<String, List<Double>> perClass = new LinkedHashMap<>();
for (int i = 0; i < values.length; i++) {
perClass.computeIfAbsent(labels.get(i), k -> new ArrayList<>()).add(values[i]);
}
Map<String, Double> classMeans = new LinkedHashMap<>();
Map<String, Double> classStdDevs = new LinkedHashMap<>();
Map<String, Integer> classCounts = new LinkedHashMap<>();
for (Map.Entry<String, List<Double>> entry : perClass.entrySet()) {
List<Double> classValues = entry.getValue();
double classSum = 0.0;
for (double v : classValues) {
classSum += v;
}
double classMean = classSum / classValues.size();
double classVar = 0.0;
for (double v : classValues) {
double diff = v - classMean;
classVar += diff * diff;
}
classVar /= classValues.size();
classMeans.put(entry.getKey(), classMean);
classStdDevs.put(entry.getKey(), Math.sqrt(classVar));
classCounts.put(entry.getKey(), classValues.size());
}
// Global mean
double globalMean = 0.0;
for (double v : values) {
globalMean += v;
}
globalMean /= values.length;
// Between-class variance (weighted by class size)
double betweenClassVar = 0.0;
for (Map.Entry<String, Double> entry : classMeans.entrySet()) {
int count = classCounts.get(entry.getKey());
double diff = entry.getValue() - globalMean;
betweenClassVar += count * diff * diff;
}
betweenClassVar /= values.length;
// Within-class variance (weighted average of per-class variances)
double withinClassVar = 0.0;
for (Map.Entry<String, Double> entry : classStdDevs.entrySet()) {
int count = classCounts.get(entry.getKey());
double std = entry.getValue();
withinClassVar += count * std * std;
}
withinClassVar /= values.length;
double fisherRatio = withinClassVar == 0.0 ? 0.0 : betweenClassVar / withinClassVar;
return new ClassSeparationScore(
name, classMeans, classStdDevs, classCounts, betweenClassVar, withinClassVar, fisherRatio);
}
/**
* Compute the maximum absolute point-biserial correlation between the feature values and any
* binary class indicator.
*/
private static double computeLabelCorrelation(double[] values, List<String> labels) {
// Collect all distinct labels
Set<String> distinctLabels = new LinkedHashSet<>(labels);
if (distinctLabels.size() <= 1) {
return 0.0;
}
double featureMean = 0.0;
for (double v : values) {
featureMean += v;
}
featureMean /= values.length;
double featureStd = 0.0;
for (double v : values) {
double diff = v - featureMean;
featureStd += diff * diff;
}
featureStd = Math.sqrt(featureStd / values.length);
if (featureStd == 0.0) {
return 0.0;
}
double maxAbsCorr = 0.0;
for (String targetLabel : distinctLabels) {
double corr = pointBiserialCorrelation(values, labels, targetLabel, featureMean, featureStd);
double absCorr = Math.abs(corr);
if (absCorr > maxAbsCorr) {
maxAbsCorr = absCorr;
}
}
return Math.min(1.0, maxAbsCorr);
}
/**
* Point-biserial correlation between a continuous feature and a binary indicator for {@code
* targetLabel}.
*/
private static double pointBiserialCorrelation(
double[] values,
List<String> labels,
String targetLabel,
double featureMean,
double featureStd) {
int n = values.length;
double sum1 = 0.0;
int n1 = 0;
for (int i = 0; i < n; i++) {
if (targetLabel.equals(labels.get(i))) {
sum1 += values[i];
n1++;
}
}
if (n1 == 0 || n1 == n) {
return 0.0;
}
int n0 = n - n1;
double mean1 = sum1 / n1;
double mean0 = (featureMean * n - sum1) / n0;
return ((mean1 - mean0) / featureStd) * Math.sqrt((double) n1 * n0 / (n * n));
}
}