Skip to content

FnMetric.kt

src/jvmMain/kotlin/afield/math/functions/FnMetric.kt · 291 lines

package and imports — 47 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Function")

package afield.math.functions

import afield.data.collections.get
import afield.data.collections.s
import afield.data.collections.times
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.geometry.mesh.gradient
import afield.graphics.render.rangeTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.cosh
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.norm
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.close
import afield.runtime.lifecycle.once
import afield.simulation.automata.slice
import afield.study.ui.numbers

import afield.audio.synth.*
import afield.data.collections.invoke
import afield.data.collections.r
import afield.math.autodiff.mean
import afield.math.autodiff.min
import afield.math.autodiff.sum
import afield.math.linalg.i
import afield.math.linalg.invoke
import afield.math.linalg.x
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.sqrt

The objective shared by both halves of the function-search family: FnMetric, a fun interface scoring a candidate signal against a target on one even sample grid (lower is better), plus a companion of ready-made metrics — rmse, mae, lp, maxError, huber, logCosh, correlation — and the fromResidual / window builders behind them.

FnMetric.lossAgainst turns a metric into the (Fn) -> Double closure the gradient fitters in FunctionFit.kt descend, sampling the target once and reusing it across the finite-difference inner loop; the same metrics are what matchFitness in FunctionEvolve.kt negates, so structural search and numeric fitting can run under one notion of "close". Any metric — including a hand-written lambda — must ignore indices where either side is non-finite and return Double.POSITIVE_INFINITY when no index is jointly finite, which callers read as maximally bad.

fun interface FnMetric

A pluggable distance metric between a target signal and a candidate, evaluated on a shared even sample grid. Lower is better; 0.0 means the candidate matches the target at every sampled point. This is the objective the differentiable fitters (fitGradient, fitBest, fitHybrid) descend; rmse is the default and one metric among the family.

The three arrays are aligned by index: xs[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) is the abscissa, target[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) the target's value there, candidate[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) the candidate's. Either value may be non-finite (NaN/±∞) where the corresponding Fn is undefined — a metric must ignore any index where either is non-finite, matching the NaN-safe grid semantics the fitters have always used. When no index is jointly finite there is nothing to compare, so a metric returns Double.POSITIVE_INFINITY; the fitter reads that as maximally-bad and freezes the offending gradient rather than descending into a NaN.

It is a fun interface, so a bespoke metric is just a lambda:

val leadingEdge = FnMetric { xs, t, c -> abs(c.first() - t.first()) }   // match the onset only
fitBest(target, seed, metric = leadingEdge)
The companion object carries the ready-made family: rmse (the default), mae, lp, maxError, huber, logCosh and correlation, each of which is one special case of "aggregate a per-sample residual, optionally weighted across the domain". RMSE is literally lp(2.0).

fun interface FnMetric {

fun distance(xs: DoubleArray, target: DoubleArray, candidate: DoubleArray): Double

The scalar distance of candidate from target over the grid xs. Lower is better; returns Double.POSITIVE_INFINITY when the two share no jointly-finite sample.

companion object

    companion object {
        private val LN2 = ln(2.0)

val rmse: FnMetric = lp(2.0)

Root-mean-square error — sqrt(mean((candidate - target)²)) over the jointly-finite grid. The default metric and the exact objective the fitters used before metrics were pluggable, so an omitted metric = argument reproduces the old numbers bit-for-bit. Identical to lp(2.0).

val mae: FnMetric = lp(1.0)

Mean absolute error — mean(|candidate - target|). Identical to lp(1.0); less swayed by a few large residuals than rmse.

fun lp(p: Double, weight: ((Double) -> Double)?

The Minkowski / Lₚ family: (mean(|candidate - target|ᵖ))^(1/p).

p = 2 is rmse, p = 1 is mae, and larger p biases the fit toward flattening the worst-matching points (approaching maxError as p → ∞). weight, if given, scales each sample's contribution by weight(x) (a weighted power mean) — pass window to fit only part of the domain, or any positive lambda to emphasise a region. Non-finite or negative weights drop that sample.

@param p the exponent, >= 1 (values below 1 are not a norm).

        fun lp(p: Double, weight: ((Double) -> Double)? = null): FnMetric {
            require(p >= 1.0) { "lp exponent p must be >= 1 (got $p)" }
            return fromResidual(root = p, weight = weight) { r -> abs(r).pow(p) }
        }

val maxError: FnMetric

Chebyshev / L∞ distance — max(|candidate - target|) over the grid. Minimising it drives down the single worst mismatch, the natural objective for a hard tolerance ("no point may drift more than ε").

        val maxError: FnMetric = FnMetric { xs, target, candidate ->
            var worst = Double.NEGATIVE_INFINITY
            var valid = 0
            for (i in xs.indices) {
                val a = candidate[i]
                val b = target[i]
                if (a.isFinite() && b.isFinite()) {
                    worst = max(worst, abs(a - b))
                    valid++
                }
            }
            if (valid == 0) Double.POSITIVE_INFINITY else worst
        }

fun huber(delta: Double = 1.0, weight: ((Double) -> Double)?

The Huber loss — squared for small residuals, linear beyond ±delta. A robust middle ground between rmse and mae: it keeps RMSE's smooth, well-conditioned gradient near the optimum while refusing to let a handful of outliers dominate the fit the way a pure sum-of-squares does. Returns the mean per-sample penalty.

@param delta the residual magnitude at which the penalty switches from quadratic to linear; > 0. Smaller = more aggressively robust.

        fun huber(delta: Double = 1.0, weight: ((Double) -> Double)? = null): FnMetric {
            require(delta > 0.0) { "huber delta must be positive (got $delta)" }
            return fromResidual(weight = weight) { r ->
                val a = abs(r)
                if (a <= delta) 0.5 * r * r else delta * (a - 0.5 * delta)
            }
        }

val logCosh: FnMetric

The log-cosh loss — mean(ln(cosh(residual))). Behaves like rmse for small residuals and like mae for large ones, but is smooth everywhere (unlike huber, which has no free knob and no kink). A gentle, parameter-free robust default.

        val logCosh: FnMetric = fromResidual { r ->
            // Numerically stable ln(cosh r) = |r| + ln(1 + e^-2|r|) - ln2, safe for large |r|.
            val a = abs(r)
            a + Math.log1p(exp(-2.0 * a)) - LN2
        }

val correlation: FnMetric

One minus the Pearson correlation between target and candidate, in [0, 2] (0 = perfectly correlated, 1 = uncorrelated, 2 = anti-correlated).

Unlike the residual metrics this is scale- and offset-blind: it rewards matching the shape of the target regardless of amplitude or vertical bias, which is what you want when you care about the gesture and will normalise later. Degenerate cases — fewer than two jointly-finite samples, or a target/candidate that is constant over the grid (zero variance, correlation undefined) — return Double.POSITIVE_INFINITY.

        val correlation: FnMetric = FnMetric { xs, target, candidate ->
            var sumA = 0.0
            var sumB = 0.0
            var n = 0
            for (i in xs.indices) {
                val a = candidate[i]
                val b = target[i]
                if (a.isFinite() && b.isFinite()) {
                    sumA += a
                    sumB += b
                    n++
                }
            }
            if (n < 2) {
                Double.POSITIVE_INFINITY
            } else {
                val meanA = sumA / n
                val meanB = sumB / n
                var cov = 0.0
                var varA = 0.0
                var varB = 0.0
                for (i in xs.indices) {
                    val a = candidate[i]
                    val b = target[i]
                    if (a.isFinite() && b.isFinite()) {
                        val da = a - meanA
                        val db = b - meanB
                        cov += da * db
                        varA += da * da
                        varB += db * db
                    }
                }
                val denom = sqrt(varA * varB)
                if (denom <= 0.0 || !denom.isFinite()) Double.POSITIVE_INFINITY
                else 1.0 - cov / denom
            }
        }

fun fromResidual

The general per-residual builder every mean-based metric above is made from: (mean(weight(x) · penalty(candidate - target)) / mean(weight))^(1/root).

Supply a penalty mapping a signed residual to a non-negative cost; with the default root = 1.0 the metric is the (weighted) mean penalty. Set root to fold in a final n-th root — e.g. lp is fromResidual(root = p) { abs(it).pow(p) }. This is the escape hatch for a custom robust loss that still wants the shared NaN-safe, weight-aware aggregation.

        fun fromResidual(
            root: Double = 1.0,
            weight: ((Double) -> Double)? = null,
            penalty: (Double) -> Double
        ): FnMetric {
            require(root >= 1.0) { "root must be >= 1 (got $root)" }
            return FnMetric { xs, target, candidate ->
                var sum = 0.0
                var weightSum = 0.0
                var valid = 0
                for (i in xs.indices) {
                    val a = candidate[i]
                    val b = target[i]
                    if (a.isFinite() && b.isFinite()) {
                        val w = weight?.invoke(xs[i]) ?: 1.0
                        if (!w.isFinite() || w < 0.0) continue
                        sum += w * penalty(a - b)
                        weightSum += w
                        valid++
                    }
                }
                if (valid == 0 || weightSum <= 0.0) {
                    Double.POSITIVE_INFINITY
                } else {
                    val mean = sum / weightSum
                    if (root == 1.0) mean else mean.pow(1.0 / root)
                }
            }
        }

fun window

A weight lambda for the residual metrics that keeps samples inside range at inside and drops those outside to outside (default 0.0) — the simplest way to fit only a slice of the domain, e.g. lp(2.0, weight = FnMetric.window(0.25..0.75)).

        fun window(
            range: ClosedRange<Double>,
            inside: Double = 1.0,
            outside: Double = 0.0
        ): (Double) -> Double = { x -> if (x in range) inside else outside }
    }
}

fun FnMetric.lossAgainst

Builds a loss closure (lower is better) that scores a candidate Fn against target under this metric, sampling both on an even grid of samples points across domain with the fitters' NaN-safe outside = NaN convention. The target is sampled once and reused, so the returned closure is cheap to call in the finite-difference inner loop.

fun FnMetric.lossAgainst(
    target: Functions.Fn,
    domain: Functions.Domain = Functions.Domain.DEFAULT,
    samples: Int = 128
): (Functions.Fn) -> Double {
    val count = samples.coerceIn(2, 8192)
    val xs = DoubleArray(count) { index -> domain.min + domain.length * index / (count - 1).toDouble() }
    val targetY = DoubleArray(count) { index -> target.evalSafe(xs[index], outside = Double.NaN) }
    return { candidate ->
        val candidateY = DoubleArray(count) { index -> candidate.evalSafe(xs[index], outside = Double.NaN) }
        distance(xs, targetY, candidateY)
    }
}