Skip to content

RbfField.kt

src/jvmMain/kotlin/afield/math/functions/RbfField.kt · 207 lines

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

package afield.math.functions

import afield.math.linalg.Vec2
import org.ejml.simple.SimpleMatrix
import kotlin.math.sqrt

Scattered-data interpolation over two or more dimensions with radial basis functions: RbfField and its rbfField builder, the multi-dimensional counterpart of Functions.rbf.

class RbfField

Radial basis function interpolation in two or more dimensions: a smooth surface through values scattered at points in a plane, or a volume, with no grid. The 1D version, RbfInterpolator behind Functions.rbf, fits a curve through (x, y) samples; this one fits f(point) through (point, value) samples, with the same RbfKernels.

The model is f(p) = Σᵢ αᵢ φ(|p − cᵢ| / rᵢ) + β₀ + β·p: one kernel bump per sample centre cᵢ, plus, with trend on, an affine term. The thin-plate kernel, the default here as it is in 1D, is only well posed with that term, and with it a plane through the samples is reproduced exactly, so the kernels only have to describe how the data departs from a tilt. The weights solve one dense linear system of size n + dimension + 1, rebuilt lazily after samples change, so this is sized like the 1D fitter: tens to a few thousand samples.

Samples can be added and removed one at a time, each with its own radius, which divides the distance before the kernel sees it: a sample with radius 2 has twice the reach of one with radius 1. leaveOneOutResiduals say how well each sample is predicted by all the others, at the cost of one matrix inverse rather than n refits, which is how to choose a kernel, a shape parameter or a smoothing.

class RbfField(
    /** The number of coordinates per point: 2 for a surface over a plane. */
    val dimension: Int,
    /** The radial function; distances are in the points' units, divided by each sample's radius. */
    val kernel: RbfKernel = RbfKernels.thinPlate,
    /**
     * Added to the kernel matrix's diagonal as a ridge term. 0 passes exactly through every sample; a
     * positive value trades that for a calmer surface through noisy data.
     */
    val smoothing: Double = 0.0,
    /**
     * Whether to fit an affine trend `β₀ + β·p` alongside the kernels. Needed for the thin-plate
     * kernel to be well posed; switched off automatically while there are fewer than
     * `dimension + 1` samples, too few to determine it.
     */
    val trend: Boolean = true,
) {

init

    init {
        require(dimension >= 1) { "dimension must be at least 1, got $dimension" }
        require(smoothing >= 0.0) { "smoothing must not be negative, got $smoothing" }
    }

    private val centres = mutableListOf<DoubleArray>()
    private val values = mutableListOf<Double>()
    private val radii = mutableListOf<Double>()

    private var alpha = DoubleArray(0)
    private var beta = DoubleArray(0)
    private var inverseDiagonal: DoubleArray? = null
    private var system: SimpleMatrix? = null
    private var dirty = true

val size: Int get() = centres.size

How many samples the field passes through.

fun add(point: DoubleArray, value: Double, radius: Double = 1.0): Int

Add a sample: value at point, reaching radius times as far as a radius-1 sample. Returns its index.

    fun add(point: DoubleArray, value: Double, radius: Double = 1.0): Int {
        require(point.size == dimension) { "expected a point with $dimension coordinates, got ${point.size}" }
        require(radius > 0.0 && radius.isFinite()) { "radius must be positive, got $radius" }
        centres += point.copyOf()
        values += value
        radii += radius
        dirty = true
        return centres.lastIndex
    }

fun add(x: Double, y: Double, value: Double, radius: Double = 1.0): Int

add for a 2D point.

    fun add(x: Double, y: Double, value: Double, radius: Double = 1.0): Int = add(doubleArrayOf(x, y), value, radius)

fun add(point: Vec2, value: Double, radius: Double = 1.0): Int

add for a 2D point.

    fun add(point: Vec2, value: Double, radius: Double = 1.0): Int = add(point.x.toDouble(), point.y.toDouble(), value, radius)

fun removeAt(index: Int)

Remove the sample at index; later samples move down one.

    fun removeAt(index: Int) {
        require(index in centres.indices) { "no sample $index; there are ${centres.size}" }
        centres.removeAt(index)
        values.removeAt(index)
        radii.removeAt(index)
        dirty = true
    }

fun clear()

Remove every sample.

    fun clear() {
        centres.clear()
        values.clear()
        radii.clear()
        dirty = true
    }

operator fun invoke(point: DoubleArray): Double

The fitted value at point. With no samples it is 0.

    operator fun invoke(point: DoubleArray): Double {
        require(point.size == dimension) { "expected a point with $dimension coordinates, got ${point.size}" }
        build()
        var sum = 0.0
        for (i in centres.indices) sum += alpha[i] * kernel.eval(distance(point, centres[i]) / radii[i])
        if (beta.isNotEmpty()) {
            sum += beta[0]
            for (k in 0 until dimension) sum += beta[k + 1] * point[k]
        }
        return sum
    }

operator fun invoke(x: Double, y: Double): Double = invoke(doubleArrayOf(x, y))

The fitted value at (x, y).

operator fun invoke(point: Vec2): Double = invoke(point.x.toDouble(), point.y.toDouble())

The fitted value at point.

fun leaveOneOut(): DoubleArray

For each sample, the value the field would predict there if that sample were left out and the rest refitted. Computed from one inverse of the system matrix (Rippa's formula), not n refits. Needs at least two samples.

    fun leaveOneOut(): DoubleArray {
        val residuals = leaveOneOutResiduals()
        return DoubleArray(size) { values[it] - residuals[it] }
    }

fun leaveOneOutResiduals(): DoubleArray

For each sample, its value minus the leaveOneOut prediction there.

    fun leaveOneOutResiduals(): DoubleArray {
        check(size >= 2) { "leave-one-out needs at least two samples, there are $size" }
        build()
        val diagonal = inverseDiagonal ?: system!!.invert().let { inv ->
            DoubleArray(size) { inv.get(it, it) }.also { inverseDiagonal = it }
        }
        return DoubleArray(size) { alpha[it] / diagonal[it] }
    }

fun leaveOneOutRmse(): Double

The root mean square of leaveOneOutResiduals: one number for how well the field generalises.

body — 48 lines, from line 142
    fun leaveOneOutRmse(): Double {
        val residuals = leaveOneOutResiduals()
        return sqrt(residuals.sumOf { it * it } / residuals.size)
    }

    private fun build() {
        if (!dirty) return
        dirty = false
        inverseDiagonal = null
        val n = centres.size
        if (n == 0) {
            alpha = DoubleArray(0)
            beta = DoubleArray(0)
            system = null
            return
        }
        val terms = if (trend && n >= dimension + 1) dimension + 1 else 0
        val size = n + terms
        val m = SimpleMatrix(size, size)
        val rhs = SimpleMatrix(size, 1)
        for (i in 0 until n) {
            for (j in 0 until n) m.set(i, j, kernel.eval(distance(centres[i], centres[j]) / radii[j]))
            m.set(i, i, m.get(i, i) + smoothing)
            rhs.set(i, 0, values[i])
            if (terms > 0) {
                // [1, p] on the right of the kernel block, and its transpose below it
                m.set(i, n, 1.0)
                m.set(n, i, 1.0)
                for (k in 0 until dimension) {
                    m.set(i, n + 1 + k, centres[i][k])
                    m.set(n + 1 + k, i, centres[i][k])
                }
            }
        }
        val solution = m.solve(rhs)
        alpha = DoubleArray(n) { solution.get(it, 0) }
        beta = DoubleArray(terms) { solution.get(n + it, 0) }
        system = m
    }

    private fun distance(p: DoubleArray, q: DoubleArray): Double {
        var s = 0.0
        for (k in p.indices) {
            val t = p[k] - q[k]
            s += t * t
        }
        return sqrt(s)
    }

override fun toString(): String

    override fun toString(): String =
        "RbfField(${dimension}D, $size samples, kernel=$kernel, smoothing=$smoothing, trend=$trend)"
}

fun rbfField

A 2D RbfField through samples, each a point and the value there. The thin-plate kernel and the affine trend are the defaults, so with no shape parameter to choose this is the one to try first.

fun rbfField(
    samples: List<Pair<Vec2, Number>>,
    kernel: RbfKernel = RbfKernels.thinPlate,
    smoothing: Double = 0.0,
    trend: Boolean = true,
): RbfField = RbfField(2, kernel, smoothing, trend).apply {
    samples.forEach { (point, value) -> add(point, value.toDouble()) }
}