ClassifierBenchmarkResult.java

package org.hammer.audio.experimental.acoustic.benchmark.classifier;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

/**
 * Result of running a {@link ClassifierBenchmark} on a labelled dataset.
 *
 * <p>Provides the full confusion matrix plus per-label precision, recall and F1, and macro-averaged
 * F1 across all labels.
 *
 * @param confusionMatrix the confusion matrix; must not be {@code null}
 * @param precisionPerLabel per-label precision ({@code null} entries indicate no predictions for
 *     that label)
 * @param recallPerLabel per-label recall ({@code null} entries indicate no samples for that label)
 * @param f1PerLabel per-label F1 ({@code null} entries indicate unavailable precision or recall)
 * @param macroF1 macro-average F1 across labels for which F1 is defined, or {@code 0} when no F1 is
 *     defined
 */
@SuppressWarnings("PMD.UseConcurrentHashMap")
public record ClassifierBenchmarkResult(
    ConfusionMatrix confusionMatrix,
    Map<String, Double> precisionPerLabel,
    Map<String, Double> recallPerLabel,
    Map<String, Double> f1PerLabel,
    double macroF1) {

  /** Validate and defensively copy fields. */
  public ClassifierBenchmarkResult {
    Objects.requireNonNull(confusionMatrix, "confusionMatrix");
    Objects.requireNonNull(precisionPerLabel, "precisionPerLabel");
    Objects.requireNonNull(recallPerLabel, "recallPerLabel");
    Objects.requireNonNull(f1PerLabel, "f1PerLabel");
    if (!Double.isFinite(macroF1) || macroF1 < 0.0 || macroF1 > 1.0) {
      throw new IllegalArgumentException("macroF1 must be finite and in [0,1]");
    }
    precisionPerLabel = Collections.unmodifiableMap(new LinkedHashMap<>(precisionPerLabel));
    recallPerLabel = Collections.unmodifiableMap(new LinkedHashMap<>(recallPerLabel));
    f1PerLabel = Collections.unmodifiableMap(new LinkedHashMap<>(f1PerLabel));
  }

  /**
   * Build a result from a confusion matrix by computing all metrics.
   *
   * @param matrix populated confusion matrix; must not be {@code null}
   * @return result record; never {@code null}
   */
  public static ClassifierBenchmarkResult of(ConfusionMatrix matrix) {
    Objects.requireNonNull(matrix, "matrix");
    Set<String> labels = matrix.labels();
    Map<String, Double> precision = new LinkedHashMap<>();
    Map<String, Double> recall = new LinkedHashMap<>();
    Map<String, Double> f1 = new LinkedHashMap<>();
    double f1Sum = 0.0;
    int f1Count = 0;
    for (String label : labels) {
      Double p = matrix.precision(label);
      Double r = matrix.recall(label);
      Double f = matrix.f1(label);
      precision.put(label, p);
      recall.put(label, r);
      f1.put(label, f);
      if (f != null) {
        f1Sum += f;
        f1Count++;
      }
    }
    double macroF1 = f1Count == 0 ? 0.0 : f1Sum / f1Count;
    return new ClassifierBenchmarkResult(matrix, precision, recall, f1, macroF1);
  }
}