Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions skainet-lang/skainet-lang-core/api/jvm/skainet-lang-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -3958,6 +3958,15 @@ public final class sk/ainet/lang/nn/metrics/AccuracyKt {
public static synthetic fun binaryAccuracy$default (FILjava/lang/Object;)Lsk/ainet/lang/nn/metrics/Metric;
}

public final class sk/ainet/lang/nn/metrics/Averaging : java/lang/Enum {
public static final field BINARY Lsk/ainet/lang/nn/metrics/Averaging;
public static final field MACRO Lsk/ainet/lang/nn/metrics/Averaging;
public static final field MICRO Lsk/ainet/lang/nn/metrics/Averaging;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lsk/ainet/lang/nn/metrics/Averaging;
public static fun values ()[Lsk/ainet/lang/nn/metrics/Averaging;
}

public abstract interface class sk/ainet/lang/nn/metrics/Metric {
public abstract fun compute ()D
public abstract fun getName ()Ljava/lang/String;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package sk.ainet.lang.nn.metrics

/**
* Reduction strategy used to aggregate per-class confusion-matrix statistics
* into a single scalar classification metric (such as Precision, Recall, or F1Score).
*
* Zero-division convention: whenever a per-class or pooled denominator is zero
* (for example, on an empty accumulator or when a class has no predicted or true samples),
* the affected score evaluates to `0.0` (never `NaN`).
*/
public enum class Averaging {
/**
* Reports the metric for the positive class (class index `1`) only.
*
* Requires binary class indices (`0` and/or `1`, i.e., at most 2 classes);
* throws [IllegalArgumentException] if any class index outside `0..1` has been observed.
* Returns `0.0` when positive class `1` has no true or predicted samples.
*/
BINARY,

/**
* Computes the metric independently for each observed class in `targets ∪ predictions`
* and returns their unweighted arithmetic mean.
*
* Every observed class contributes equally regardless of class support. Classes that
* never appear in either predictions or targets are excluded from the average; returns
* `0.0` when no samples have been accumulated.
*/
MACRO,

/**
* Pools true positives, false positives, and false negatives across all observed
* classes first (`∑TP`, `∑FP`, `∑FN`), then applies the metric formula once.
*
* Every sample contributes equally; for single-label multi-class classification,
* micro-averaged precision, recall, and F1 all equal overall classification accuracy.
* Returns `0.0` when no samples have been accumulated.
*/
MICRO
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package sk.ainet.lang.nn.metrics

import sk.ainet.lang.tensor.Tensor
import sk.ainet.lang.types.DType

/**
* Immutable snapshot of per-class confusion-matrix counts.
*
* @property tp True positives for the class (`predicted == c && target == c`).
* @property fp False positives for the class (`predicted == c && target != c`).
* @property fn False negatives for the class (`predicted != c && target == c`).
*/
internal data class ClassCounts(
val tp: Long,
val fp: Long,
val fn: Long
) {
init {
require(tp >= 0L && fp >= 0L && fn >= 0L) {
"ClassCounts must be non-negative, got tp=$tp, fp=$fp, fn=$fn"
}
}
}

/**
* Stateful per-class confusion-matrix accumulator shared by classification metrics
* (`Precision`, `Recall`, `F1Score`).
*
* Walks batches via [ClassificationSupport.forEachPrediction] and maintains sparse
* counters over the union of classes observed in `targets ∪ predictions`.
*
* @param dim Dimension along which class logits/probabilities lie (default: `-1`, last dimension).
* @param threshold Optional threshold for binary classification (`classCount <= 2`).
*/
internal class ConfusionMatrixAccumulator(
private val dim: Int = -1,
private val threshold: Float? = null
) {
private class MutableCounts(
var tp: Long = 0L,
var fp: Long = 0L,
var fn: Long = 0L
)

private val counters = mutableMapOf<Int, MutableCounts>()

internal fun <T : DType, V> update(
predictions: Tensor<T, V>,
targets: Tensor<out DType, *>
) {
ClassificationSupport.forEachPrediction(predictions, targets, dim, threshold) { predicted, target ->
if (predicted == target) {
counters.getOrPut(target) { MutableCounts() }.tp++
} else {
counters.getOrPut(predicted) { MutableCounts() }.fp++
counters.getOrPut(target) { MutableCounts() }.fn++
}
}
}

internal fun counts(): Map<Int, ClassCounts> =
counters.mapValues { (_, c) -> ClassCounts(c.tp, c.fp, c.fn) }

internal fun reset() {
counters.clear()
}
}

/**
* Reduces per-class [counts] into a single scalar metric according to [averaging],
* applying the `0.0` zero-division convention (never `NaN`) in one place.
*
* @param counts Per-class `(tp, fp, fn)` counts, typically from [ConfusionMatrixAccumulator.counts].
* @param averaging Aggregation mode ([Averaging.BINARY], [Averaging.MACRO], or [Averaging.MICRO]).
* @param perClass Metric formula mapping `(tp, fp, fn)` to a score. Any non-finite value
* (`NaN` or `Infinity` from zero-division) is normalized to `0.0`.
*/
internal fun reduce(
counts: Map<Int, ClassCounts>,
averaging: Averaging,
perClass: (tp: Long, fp: Long, fn: Long) -> Double
): Double = when (averaging) {
Averaging.BINARY -> {
require(counts.keys.all { it == 0 || it == 1 }) {
"Averaging.BINARY requires binary class indices (0 or 1), got classes ${counts.keys.sorted()}"
}
val positive = counts[1]
if (positive == null || positive.tp + positive.fp + positive.fn == 0L) {
0.0
} else {
evalSafe(positive.tp, positive.fp, positive.fn, perClass)
}
}

Averaging.MACRO -> {
if (counts.isEmpty()) {
0.0
} else {
var sum = 0.0
for (c in counts.values) {
sum += if (c.tp + c.fp + c.fn == 0L) 0.0 else evalSafe(c.tp, c.fp, c.fn, perClass)
}
sum / counts.size
}
}

Averaging.MICRO -> {
if (counts.isEmpty()) {
0.0
} else {
var totalTp = 0L
var totalFp = 0L
var totalFn = 0L
for (c in counts.values) {
totalTp += c.tp
totalFp += c.fp
totalFn += c.fn
}
if (totalTp + totalFp + totalFn == 0L) {
0.0
} else {
evalSafe(totalTp, totalFp, totalFn, perClass)
}
}
}
}

private inline fun evalSafe(
tp: Long,
fp: Long,
fn: Long,
perClass: (tp: Long, fp: Long, fn: Long) -> Double
): Double {
val value = perClass(tp, fp, fn)
return if (!value.isFinite()) 0.0 else value
}
Loading
Loading