Skip to content

LeastSquares.kt

src/jvmMain/kotlin/afield/math/optimization/LeastSquares.kt · 328 lines

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

package afield.math.optimization

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.graphics.render.rangeTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.exp
import afield.math.autodiff.minus
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.times
import afield.runtime.lifecycle.once

import afield.animation.keyframes.h
import afield.audio.synth.*
import afield.data.collections.invoke
import afield.data.collections.r
import afield.geometry.mesh.gradient
import afield.lang.stacksl.h
import afield.math.autodiff.AD
import afield.math.autodiff.ADTape
import afield.math.autodiff.jacobian
import afield.math.autodiff.maxOf
import afield.math.autodiff.sum
import afield.math.linalg.i
import afield.math.linalg.invoke
import afield.math.linalg.rr
import afield.math.linalg.x
import afield.simulation.agents.step
import afield.simulation.automata.step
import afield.ui.output.h
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sqrt

A Levenberg–Marquardt nonlinear least-squares solver, pure Kotlin and self-contained.

leastSquares takes an initial parameter vector and a residuals: (DoubleArray) -> DoubleArray lambda and minimises half the squared residual norm. LeastSquaresOptions controls the iteration cap, the damping schedule and three convergence tolerances; LeastSquaresResult reports the parameters, final residuals, cost, iteration count, an rms, and a reason string — one of "gradient", "step", "improvement", "no accepted step" or "max iterations".

The Jacobian is central finite differences by default, so you never have to supply derivatives, but each iteration then costs 2 * parameters extra residual evaluations. Pass an explicit jacobian — or, easier, write the residuals in AD arithmetic and call leastSquaresAD, which derives an exact Jacobian by reverse-mode automatic differentiation — to trade those 2n evaluations for one backward sweep and get derivatives that are exact rather than truncated.

The damped normal equations are solved by Gaussian elimination with partial pivoting; a singular system or a non-finite candidate raises the damping and retries (up to 12 times per iteration) rather than failing. Non-finite initial residuals throw.

data class LeastSquaresOptions

data class LeastSquaresOptions(
    val maxIterations: Int = 64,
    val initialDamping: Double = 1e-3,
    val dampingUp: Double = 10.0,
    val dampingDown: Double = 0.35,
    val finiteDifferenceStep: Double = 1e-4,
    val gradientTolerance: Double = 1e-8,
    val stepTolerance: Double = 1e-9,
    val improvementTolerance: Double = 1e-12
) {

init

    init {
        require(maxIterations > 0) { "maxIterations must be > 0" }
        require(initialDamping > 0.0) { "initialDamping must be > 0" }
        require(dampingUp > 1.0) { "dampingUp must be > 1" }
        require(dampingDown in 0.0..1.0) { "dampingDown must be in 0..1" }
        require(finiteDifferenceStep > 0.0) { "finiteDifferenceStep must be > 0" }
        require(gradientTolerance >= 0.0) { "gradientTolerance must be >= 0" }
        require(stepTolerance >= 0.0) { "stepTolerance must be >= 0" }
        require(improvementTolerance >= 0.0) { "improvementTolerance must be >= 0" }
    }
}

data class LeastSquaresResult

data class LeastSquaresResult(
    val x: DoubleArray,
    val residuals: DoubleArray,
    val cost: Double,
    val iterations: Int,
    val converged: Boolean,
    val reason: String
) {

val rms: Double get() = if (residuals.isEmpty()) 0.0 else sqrt(2.0 * cost / residuals.size)

fun leastSquares

Levenberg-Marquardt minimisation of half the squared residual norm.

@param initial the starting parameter vector; must be non-empty and give finite residuals. @param options the iteration cap, damping schedule and convergence tolerances. @param jacobian an exact ∂rᵢ/∂xⱼ (rows = residuals, columns = parameters). Null is the default, and then the Jacobian is central finite differences at 2n extra residual evaluations per iteration. leastSquaresAD fills this in for you from residuals written in AD arithmetic. @param residuals the residual vector at a parameter vector.

body — 94 lines, from line 114
fun leastSquares(
    initial: DoubleArray,
    options: LeastSquaresOptions = LeastSquaresOptions(),
    jacobian: ((DoubleArray) -> Array<DoubleArray>)? = null,
    residuals: (DoubleArray) -> DoubleArray
): LeastSquaresResult {
    require(initial.isNotEmpty()) { "initial must not be empty" }

    var x = initial.clone()
    var r = residuals(x)
    require(r.all { it.isFinite() }) { "initial residuals contained non-finite values" }
    var cost = halfSquaredNorm(r)
    var damping = options.initialDamping

    repeat(options.maxIterations) { iteration ->
        val j = jacobian?.invoke(x) ?: finiteDifferenceJacobian(x, r, options.finiteDifferenceStep, residuals)
        val normal = Array(x.size) { DoubleArray(x.size) }
        val gradient = DoubleArray(x.size)

        for (row in r.indices) {
            val rr = r[row]
            for (col in x.indices) {
                val jc = j[row][col]
                gradient[col] += jc * rr
                for (col2 in x.indices) {
                    normal[col][col2] += jc * j[row][col2]
                }
            }
        }

        val gradientInf = gradient.maxOf { abs(it) }
        if (gradientInf <= options.gradientTolerance) {
            return LeastSquaresResult(x, r, cost, iteration, converged = true, reason = "gradient")
        }

        var accepted = false
        var acceptedX = x
        var acceptedR = r
        var acceptedCost = cost
        var acceptedStepNorm = 0.0

        repeat(12) {
            val damped = Array(x.size) { i -> normal[i].clone() }
            for (i in x.indices) {
                damped[i][i] += damping * max(1.0, normal[i][i])
            }

            val rhs = DoubleArray(x.size) { i -> -gradient[i] }
            val step = solveLinearSystem(damped, rhs)
            if (step == null) {
                damping *= options.dampingUp
                return@repeat
            }

            val candidateX = x + step
            val candidateR = residuals(candidateX)
            if (!candidateR.all { it.isFinite() }) {
                damping *= options.dampingUp
                return@repeat
            }

            val candidateCost = halfSquaredNorm(candidateR)
            if (candidateCost < cost) {
                accepted = true
                acceptedX = candidateX
                acceptedR = candidateR
                acceptedCost = candidateCost
                acceptedStepNorm = norm(step)
                damping = max(damping * options.dampingDown, 1e-15)
                return@repeat
            }

            damping *= options.dampingUp
        }

        if (!accepted) {
            return LeastSquaresResult(x, r, cost, iteration + 1, converged = false, reason = "no accepted step")
        }

        val improvement = cost - acceptedCost
        x = acceptedX
        r = acceptedR
        cost = acceptedCost

        if (acceptedStepNorm <= options.stepTolerance) {
            return LeastSquaresResult(x, r, cost, iteration + 1, converged = true, reason = "step")
        }
        if (improvement <= options.improvementTolerance) {
            return LeastSquaresResult(x, r, cost, iteration + 1, converged = true, reason = "improvement")
        }
    }

    return LeastSquaresResult(x, r, cost, options.maxIterations, converged = false, reason = "max iterations")
}

fun leastSquaresAD

leastSquares with an exact Jacobian, from residuals written once in AD arithmetic.

The residual lambda takes the parameters as tracked AD values and returns the residual list; the plain values drive the Levenberg-Marquardt loop and one reverse-mode sweep per row supplies the Jacobian. That replaces 2n residual evaluations per iteration with a single forward pass, and removes LeastSquaresOptions.finiteDifferenceStep from the things you have to get right.

val xs = doubleArrayOf(0.0, 0.25, 0.5, 0.75, 1.0)
val ys = xs.map { 2.3 * exp(-1.7 * it) + 0.4 }
val fit = leastSquaresAD(doubleArrayOf(1.0, -1.0, 0.0)) { p ->
    xs.indices.map { i -> p[0] * exp(p[1] * xs[i]) + p[2] - ys[i] }
}

The residual lambda must be expressible in AD arithmetic all the way down — a table lookup or a when on a parameter's value will differentiate only the branch taken, which is correct for the piecewise function but not for the one you may have meant.

body — 99 lines, from line 229
fun leastSquaresAD(
    initial: DoubleArray,
    options: LeastSquaresOptions = LeastSquaresOptions(),
    residuals: (List<AD>) -> List<AD>
): LeastSquaresResult = leastSquares(
    initial = initial,
    options = options,
    jacobian = { x -> jacobian(x) { p -> residuals(p) } },
    residuals = { x ->
        val tape = ADTape()
        val inputs = tape.variables(x)
        residuals(inputs).map { it.value }.toDoubleArray()
    }
)

private fun finiteDifferenceJacobian(
    x: DoubleArray,
    baseResiduals: DoubleArray,
    stepScale: Double,
    residuals: (DoubleArray) -> DoubleArray
): Array<DoubleArray> {
    val out = Array(baseResiduals.size) { DoubleArray(x.size) }

    for (col in x.indices) {
        val h = stepScale * max(1.0, abs(x[col]))
        val xp = x.clone()
        val xm = x.clone()
        xp[col] += h
        xm[col] -= h

        val rp = residuals(xp)
        val rm = residuals(xm)
        val denom = 2.0 * h

        for (row in baseResiduals.indices) {
            out[row][col] = (rp[row] - rm[row]) / denom
        }
    }

    return out
}

private operator fun DoubleArray.plus(other: DoubleArray): DoubleArray {
    require(size == other.size) { "vector sizes differ" }
    return DoubleArray(size) { i -> this[i] + other[i] }
}

private fun halfSquaredNorm(v: DoubleArray): Double =
    0.5 * v.sumOf { it * it }

private fun norm(v: DoubleArray): Double =
    sqrt(v.sumOf { it * it })

private fun solveLinearSystem(aIn: Array<DoubleArray>, bIn: DoubleArray): DoubleArray? {
    val n = bIn.size
    val a = Array(n) { i -> aIn[i].clone() }
    val b = bIn.clone()

    for (k in 0 until n) {
        var pivot = k
        var pivotAbs = abs(a[k][k])
        for (i in k + 1 until n) {
            val candidate = abs(a[i][k])
            if (candidate > pivotAbs) {
                pivot = i
                pivotAbs = candidate
            }
        }
        if (pivotAbs < 1e-14 || !pivotAbs.isFinite()) return null

        if (pivot != k) {
            val row = a[k]
            a[k] = a[pivot]
            a[pivot] = row
            val bv = b[k]
            b[k] = b[pivot]
            b[pivot] = bv
        }

        for (i in k + 1 until n) {
            val factor = a[i][k] / a[k][k]
            a[i][k] = 0.0
            for (j in k + 1 until n) {
                a[i][j] -= factor * a[k][j]
            }
            b[i] -= factor * b[k]
        }
    }

    val x = DoubleArray(n)
    for (i in n - 1 downTo 0) {
        var sum = b[i]
        for (j in i + 1 until n) {
            sum -= a[i][j] * x[j]
        }
        x[i] = sum / a[i][i]
    }
    return x
}