Skip to content

smile_interpolation_regression.kt

src/jvmMain/kotlin/afield/math/statistics/smile_interpolation_regression.kt · 168 lines

@file:afield.runtime.completion.CompletionCategory("Math")

package afield.math.statistics

import afield.data.collections.invoke
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.normalized
import afield.graphics.render.rangeTo
import afield.graphics.shader.callable
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.invoke
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.normalized

import afield.audio.synth.*
import afield.data.collections.get
import afield.math.autodiff.abs
import afield.math.autodiff.maxOf
import afield.math.linalg.i
import afield.math.linalg.x
import afield.math.gp.GpJoint
import afield.math.gp.GpPrediction
import smile.interpolation.CubicSplineInterpolation1D
import smile.math.kernel.GaussianKernel
import smile.regression.GaussianProcessRegression

Two 1D fitters that wrap the Smile library: smileCubicSpline (cubic spline interpolation through the data) and smileGaussianProcess (Gaussian-process regression with a Gaussian kernel, configured by SmileGpOptions).

Both take parallel xs/ys arrays, copy them, and require at least two finite samples with xs strictly increasing — a violation throws rather than producing a bad fit with no complaint. The returned SmileCubicSpline1D / SmileGaussianProcess1D are callable via invoke and also expose predict for a single x or for a whole array.

GP fitting failures propagate. No interpolator is substituted for a failed probabilistic model. For 1D/2D/3D inference, composed kernels, heteroscedastic noise and coherent samples see afield.math.gp.GaussianProcess.

data class SmileGpOptions

data class SmileGpOptions(
    val lengthScale: Double = 0.15,
    /** Smile uses noise VARIANCE in normalized output units when normalize is enabled. New GP APIs use noiseStd. */
    val noise: Double = 1e-3,
    val normalize: Boolean = true,
    val tol: Double = 1e-4,
    val maxIter: Int = 500
)

class SmileCubicSpline1D internal constructor

class SmileCubicSpline1D internal constructor(
    val xs: DoubleArray,
    val ys: DoubleArray,
    private val model: CubicSplineInterpolation1D
) {

fun predict(x: Double): Double = model.interpolate(x)

fun predict(xs: DoubleArray): DoubleArray

    fun predict(xs: DoubleArray): DoubleArray =
        DoubleArray(xs.size) { i -> predict(xs[i]) }

operator fun invoke(x: Double): Double = predict(x)

class SmileGaussianProcess1D internal constructor

class SmileGaussianProcess1D internal constructor(
    val xs: DoubleArray,
    val ys: DoubleArray,
    val options: SmileGpOptions,
    private val model: GaussianProcessRegression<DoubleArray>
) {

fun predict(x: Double): Double = model.predict(doubleArrayOf(x))

fun predictWithVariance(x: Double): GpPrediction

Latent posterior uncertainty in the original output units.

    fun predictWithVariance(x: Double): GpPrediction {
        require(x.isFinite())
        val estimate = DoubleArray(2)
        model.predict(doubleArrayOf(x), estimate)
        require(estimate.all { it.isFinite() }) { "Smile returned a non-finite prediction" }
        return GpPrediction(estimate[0], estimate[1] * estimate[1])
    }

fun posterior(xs: DoubleArray): GpJoint

Seedable coherent draws through Field's semidefinite-aware joint sampler.

    fun posterior(xs: DoubleArray): GpJoint {
        require(xs.isNotEmpty() && xs.size <= 4096 && xs.all { it.isFinite() })
        val prediction = model.query(Array(xs.size) { doubleArrayOf(xs[it]) })
        val covariance = Array(xs.size) { i -> DoubleArray(xs.size) { j -> prediction.cov.get(i, j) } }
        val scale = covariance.indices.maxOf { kotlin.math.abs(covariance[it][it]) }
        return GpJoint(prediction.mu.copyOf(), covariance, 1e-12, 1e-10 * scale)
    }

fun predict(xs: DoubleArray): DoubleArray

    fun predict(xs: DoubleArray): DoubleArray =
        DoubleArray(xs.size) { i -> predict(xs[i]) }

operator fun invoke(x: Double): Double = predict(x)

fun smileCubicSpline(xs: DoubleArray, ys: DoubleArray): SmileCubicSpline1D

fun smileCubicSpline(xs: DoubleArray, ys: DoubleArray): SmileCubicSpline1D {
    validateTrainingSeries(xs, ys, "smileCubicSpline")
    val xCopy = xs.copyOf()
    val yCopy = ys.copyOf()
    val spline = CubicSplineInterpolation1D(xCopy, yCopy)
    return SmileCubicSpline1D(xCopy, yCopy, spline)
}

fun smileGaussianProcess

body — 49 lines, from line 118
fun smileGaussianProcess(
    xs: DoubleArray,
    ys: DoubleArray,
    options: SmileGpOptions = SmileGpOptions()
): SmileGaussianProcess1D {
    validateTrainingSeries(xs, ys, "smileGaussianProcess")
    require(options.lengthScale.isFinite() && options.lengthScale > 0.0) {
        "smileGaussianProcess: lengthScale must be > 0, found ${options.lengthScale}"
    }
    require(options.noise.isFinite() && options.noise >= 0.0) {
        "smileGaussianProcess: noise must be >= 0, found ${options.noise}"
    }
    require(options.tol.isFinite() && options.tol > 0.0) {
        "smileGaussianProcess: tol must be > 0, found ${options.tol}"
    }
    require(options.maxIter >= 0) {
        "smileGaussianProcess: maxIter must be >= 0, found ${options.maxIter}"
    }

    val xCopy = xs.copyOf()
    val yCopy = ys.copyOf()
    val xTrain = Array(xCopy.size) { i -> doubleArrayOf(xCopy[i]) }
    val kernel = GaussianKernel(options.lengthScale)
    // Smile's normalization divides by sample standard deviation; constant data has zero spread.
    val normalize = options.normalize && yCopy.any { it != yCopy[0] }
    val gpOptions = GaussianProcessRegression.Options(options.noise, normalize, options.tol, options.maxIter)
    val model = GaussianProcessRegression.fit(xTrain, yCopy, kernel, gpOptions)
    return SmileGaussianProcess1D(xCopy, yCopy, options, model)
}

private fun validateTrainingSeries(xs: DoubleArray, ys: DoubleArray, fn: String) {
    require(xs.size == ys.size) {
        "$fn: xs.size (${xs.size}) must match ys.size (${ys.size})"
    }
    require(xs.size >= 2) {
        "$fn: at least 2 samples required, got ${xs.size}"
    }

    for (i in xs.indices) {
        require(xs[i].isFinite()) { "$fn: xs[$i] must be finite, found ${xs[i]}" }
        require(ys[i].isFinite()) { "$fn: ys[$i] must be finite, found ${ys[i]}" }
    }

    for (i in 1 until xs.size) {
        require(xs[i] > xs[i - 1]) {
            "$fn: xs must be strictly increasing; xs[$i]=${xs[i]} <= xs[${i - 1}]=${xs[i - 1]}"
        }
    }
}