FeatureHistogram.java

package org.hammer.audio.experimental.acoustic.dataset;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;

/**
 * Histogram of a single numeric feature computed from a collection of recordings.
 *
 * <p>Bucket boundaries are determined automatically from the value range using Sturges' rule when
 * no explicit bucket count is supplied. All bucket counts are non-negative integers and the buckets
 * partition the observed range without overlap.
 *
 * <p>An empty histogram (no values) has an empty bucket list and is a valid representation of a
 * feature for which no data is available. Use {@link #toMarkdown()} to render a human-readable
 * table for inclusion in evaluation reports.
 *
 * @param featureName human-readable name of the feature; must not be blank
 * @param buckets ordered list of histogram buckets; may be empty when the input is empty
 */
@SuppressWarnings("PMD.DanglingJavadoc")
public record FeatureHistogram(String featureName, List<Bucket> buckets) {

  /** Validate and defensively copy fields. */
  public FeatureHistogram {
    Objects.requireNonNull(featureName, "featureName");
    Objects.requireNonNull(buckets, "buckets");
    if (featureName.isBlank()) {
      throw new IllegalArgumentException("featureName must not be blank");
    }
    buckets = List.copyOf(buckets);
  }

  /**
   * Build a histogram from an array of values using an automatically determined bucket count.
   *
   * <p>The bucket count is chosen with Sturges' rule: {@code ceil(log2(n)) + 1}, clamped to at
   * least {@code 1}. When all values are identical, a single bucket is returned.
   *
   * @param featureName human-readable feature name; must not be blank
   * @param values array of observed values; must not be {@code null}; may be empty
   * @return histogram; never {@code null}
   */
  public static FeatureHistogram of(String featureName, double[] values) {
    Objects.requireNonNull(values, "values");
    return of(featureName, values, optimalBucketCount(values.length));
  }

  /**
   * Build a histogram from an array of values with a specified bucket count.
   *
   * @param featureName human-readable feature name; must not be blank
   * @param values array of observed values; must not be {@code null}; may be empty
   * @param bucketCount desired number of buckets; must be {@code >= 1}
   * @return histogram; never {@code null}
   */
  public static FeatureHistogram of(String featureName, double[] values, int bucketCount) {
    Objects.requireNonNull(featureName, "featureName");
    Objects.requireNonNull(values, "values");
    if (bucketCount < 1) {
      throw new IllegalArgumentException("bucketCount must be >= 1");
    }
    if (values.length == 0) {
      return new FeatureHistogram(featureName, List.of());
    }
    double min = values[0];
    double max = values[0];
    for (double v : values) {
      if (v < min) {
        min = v;
      }
      if (v > max) {
        max = v;
      }
    }
    if (Double.doubleToLongBits(min) == Double.doubleToLongBits(max)) {
      return new FeatureHistogram(featureName, List.of(new Bucket(min, max, values.length)));
    }
    double range = max - min;
    double bucketWidth = range / bucketCount;
    int[] counts = new int[bucketCount];
    for (double v : values) {
      int idx = (int) ((v - min) / bucketWidth);
      if (idx >= bucketCount) {
        idx = bucketCount - 1;
      }
      counts[idx]++;
    }
    List<Bucket> buckets = new ArrayList<>(bucketCount);
    for (int i = 0; i < bucketCount; i++) {
      double lower = min + i * bucketWidth;
      double upper = min + (i + 1) * bucketWidth;
      buckets.add(new Bucket(lower, upper, counts[i]));
    }
    return new FeatureHistogram(featureName, buckets);
  }

  /**
   * Render this histogram as a Markdown table section.
   *
   * @return Markdown snippet; never {@code null}
   */
  @SuppressWarnings("PMD.ConsecutiveLiteralAppends")
  public String toMarkdown() {
    if (buckets.isEmpty()) {
      return "### " + featureName + "\n\n- No data\n\n";
    }
    StringBuilder sb = new StringBuilder(256);
    sb.append("### ").append(featureName).append("\n\n").append("| Range | Count |\n|---|---:|\n");
    for (Bucket b : buckets) {
      sb.append("| [")
          .append(String.format(Locale.ROOT, "%.3f", b.lowerBound()))
          .append(", ")
          .append(String.format(Locale.ROOT, "%.3f", b.upperBound()))
          .append(") | ")
          .append(b.count())
          .append(" |\n");
    }
    sb.append('\n');
    return sb.toString();
  }

  /**
   * Compute the optimal bucket count for {@code n} observations using Sturges' rule.
   *
   * @param n number of observations
   * @return number of buckets, always {@code >= 1}
   */
  static int optimalBucketCount(int n) {
    if (n <= 1) {
      return 1;
    }
    return Math.max(1, (int) Math.ceil(Math.log(n) / Math.log(2.0)) + 1);
  }

  /**
   * One bucket of a {@link FeatureHistogram}.
   *
   * @param lowerBound inclusive lower boundary of the bucket
   * @param upperBound exclusive upper boundary of the bucket (except for the last bucket, which is
   *     inclusive)
   * @param count number of observations falling into this bucket; must be {@code >= 0}
   */
  @SuppressWarnings("PMD.DanglingJavadoc")
  public record Bucket(double lowerBound, double upperBound, int count) {

    /** Validate bucket fields. */
    public Bucket {
      if (!Double.isFinite(lowerBound)) {
        throw new IllegalArgumentException("lowerBound must be finite");
      }
      if (!Double.isFinite(upperBound)) {
        throw new IllegalArgumentException("upperBound must be finite");
      }
      if (count < 0) {
        throw new IllegalArgumentException("count must be >= 0");
      }
    }
  }
}