feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package stats
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// TestGPInterpolatesNoiselessTrainingPoints fits a noiseless GP on its
|
||||
// own training points: the posterior must reproduce every observation
|
||||
// to rounding and carry no variance there.
|
||||
func TestGPInterpolatesNoiselessTrainingPoints(t *testing.T) {
|
||||
g := core.NewGenerator(41)
|
||||
const n = 9
|
||||
train := core.New(core.Float, n, 1)
|
||||
y := core.New(core.Float, n)
|
||||
for i := range n {
|
||||
x := -2 + 0.5*float64(i)
|
||||
train.RawFloats()[i] = x
|
||||
y.RawFloats()[i] = math.Sin(x) + 0.1*x*g.Unit()
|
||||
}
|
||||
kernel, err := SquaredExponentialKernel(0.7)
|
||||
if err != nil {
|
||||
t.Fatalf("SquaredExponentialKernel: %v", err)
|
||||
}
|
||||
res, err := GaussianProcessRegression(kernel, train, y, 0, train)
|
||||
if err != nil {
|
||||
t.Fatalf("GaussianProcessRegression: %v", err)
|
||||
}
|
||||
for i := range n {
|
||||
if math.Abs(res.Mean[i]-y.FloatAt(i)) > 1e-9 {
|
||||
t.Fatalf("point %d: posterior mean %.12g against the observation %.12g",
|
||||
i, res.Mean[i], y.FloatAt(i))
|
||||
}
|
||||
if res.Variance[i] > 1e-12 {
|
||||
t.Fatalf("point %d: posterior variance %.3g at a noiseless training point", i, res.Variance[i])
|
||||
}
|
||||
}
|
||||
// The full posterior covariance vanishes on the training set too:
|
||||
// the conditioning has removed everything the prior had there.
|
||||
for i := range n {
|
||||
for j := range n {
|
||||
if math.Abs(res.Covariance[i*n+j]) > 1e-10 {
|
||||
t.Fatalf("posterior covariance (%d, %d) = %.3g at the training points, want zero",
|
||||
i, j, res.Covariance[i*n+j])
|
||||
}
|
||||
}
|
||||
}
|
||||
// The reported marginal likelihood must match the standalone
|
||||
// function bit for bit on the same inputs.
|
||||
mll, err := MarginalLogLikelihood(kernel, train, y, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("MarginalLogLikelihood: %v", err)
|
||||
}
|
||||
if mll != res.LogLikelihood {
|
||||
t.Fatalf("the fit reported %.17g but the standalone %.17g", res.LogLikelihood, mll)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGPHugeLengthScaleFollowsGlobalMean pins the limiting behaviour
|
||||
// of a giant length scale: when the prior cannot tell neighbouring
|
||||
// inputs apart, the posterior mean collapses onto the constant that
|
||||
// the likelihood alone supports, the sample mean of the training
|
||||
// responses. A small noise keeps the Gram matrix well conditioned;
|
||||
// with n = 30 and noise 1e-6 the constant fit sits within 1e-7 of the
|
||||
// mean, far inside the 1e-4 tolerance.
|
||||
func TestGPHugeLengthScaleFollowsGlobalMean(t *testing.T) {
|
||||
g := core.NewGenerator(43)
|
||||
const n = 30
|
||||
train := core.New(core.Float, n, 1)
|
||||
y := core.New(core.Float, n)
|
||||
total := 0.0
|
||||
for i := range n {
|
||||
train.RawFloats()[i] = float64(i) / float64(n-1)
|
||||
y.RawFloats()[i] = 3 + 0.4*g.NormalUnit()
|
||||
total += y.FloatAt(i)
|
||||
}
|
||||
meanY := total / n
|
||||
kernel, err := SquaredExponentialKernel(1e6)
|
||||
if err != nil {
|
||||
t.Fatalf("SquaredExponentialKernel: %v", err)
|
||||
}
|
||||
test := mustFloats(t, []float64{-5, 0.37, 12}, 3, 1)
|
||||
res, err := GaussianProcessRegression(kernel, train, y, 1e-6, test)
|
||||
if err != nil {
|
||||
t.Fatalf("GaussianProcessRegression: %v", err)
|
||||
}
|
||||
for i, m := range res.Mean {
|
||||
if math.Abs(m-meanY) > 1e-4 {
|
||||
t.Fatalf("test point %d: posterior mean %.8g against the global mean %.8g", i, m, meanY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGPVarianceFarFromDataEqualsPrior pins the uncertainty
|
||||
// behaviour: a test point many length scales from every observation
|
||||
// has learned nothing, so its posterior variance returns to the prior
|
||||
// variance of 1 and its posterior covariance with the data region
|
||||
// vanishes.
|
||||
func TestGPVarianceFarFromDataEqualsPrior(t *testing.T) {
|
||||
const n = 6
|
||||
train := mustFloats(t, []float64{0, 0.2, 0.4, 0.6, 0.8, 1.0}, n, 1)
|
||||
y := mustFloats(t, []float64{1, -1, 0.5, -0.5, 2, -2}, n)
|
||||
kernel, err := SquaredExponentialKernel(0.3)
|
||||
if err != nil {
|
||||
t.Fatalf("SquaredExponentialKernel: %v", err)
|
||||
}
|
||||
// 30 is one hundred length scales from the nearest observation.
|
||||
test := mustFloats(t, []float64{0.5, 30}, 2, 1)
|
||||
res, err := GaussianProcessRegression(kernel, train, y, 0, test)
|
||||
if err != nil {
|
||||
t.Fatalf("GaussianProcessRegression: %v", err)
|
||||
}
|
||||
if v := res.Variance[1]; math.Abs(v-1) > 1e-10 {
|
||||
t.Fatalf("far posterior variance %.15g, want the prior 1", v)
|
||||
}
|
||||
// The far point's covariance with the data region: the cross
|
||||
// covariances are underflows, so the off-diagonal entry must be a
|
||||
// rounding dust.
|
||||
if c := res.Covariance[0*2+1]; math.Abs(c) > 1e-10 {
|
||||
t.Fatalf("far covariance with the data region %.3g, want a rounding dust", c)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatern32MatchesSpectralForm holds the Matérn 3/2 closed form
|
||||
// against its defining spectral equation: the density
|
||||
// S(ω) = 4a³/(a² + ω²)², a = √3/L, inverts to the kernel through
|
||||
// k(r) = (1/π)∫₀^∞ S(ω)·cos(ω·r) dω. The integral is evaluated by
|
||||
// trapezoid out to five hundred decay widths, where the integrand's
|
||||
// ω⁻⁴ tail has less than 1e-7 left to give.
|
||||
func TestMatern32MatchesSpectralForm(t *testing.T) {
|
||||
const lengthScale = 1.3
|
||||
kernel, err := Matern32Kernel(lengthScale)
|
||||
if err != nil {
|
||||
t.Fatalf("Matern32Kernel: %v", err)
|
||||
}
|
||||
a := math.Sqrt(3) / lengthScale
|
||||
const omegaMax = 500
|
||||
const steps = 400000
|
||||
h := omegaMax / float64(steps)
|
||||
spectral := func(r float64) float64 {
|
||||
total := 0.0
|
||||
for s := range steps + 1 {
|
||||
omega := float64(s) * h
|
||||
w := 1.0
|
||||
if s == 0 || s == steps {
|
||||
w = 0.5
|
||||
}
|
||||
den := a*a + omega*omega
|
||||
total += w * 4 * a * a * a / (den * den) * math.Cos(omega*r)
|
||||
}
|
||||
return total * h / math.Pi
|
||||
}
|
||||
for _, r := range []float64{0, 0.7, lengthScale, 2.9} {
|
||||
want := kernel.Covariance([]float64{r}, []float64{0})
|
||||
got := spectral(r)
|
||||
if math.Abs(got-want) > 1e-6 {
|
||||
t.Fatalf("r = %g: closed form %.9g against the spectral inversion %.9g", r, want, got)
|
||||
}
|
||||
}
|
||||
// And the spot values of both Matérns: k(0) = 1 and the documented
|
||||
// closed forms at one length scale, r = L, where the shape
|
||||
// parameter a = √3 (respectively √5) exactly.
|
||||
m52, err := Matern52Kernel(lengthScale)
|
||||
if err != nil {
|
||||
t.Fatalf("Matern52Kernel: %v", err)
|
||||
}
|
||||
if kernel.Covariance([]float64{0}, []float64{0}) != 1 {
|
||||
t.Fatal("the Matern 3/2 kernel is not of unit amplitude")
|
||||
}
|
||||
if m52.Covariance([]float64{0}, []float64{0}) != 1 {
|
||||
t.Fatal("the Matern 5/2 kernel is not of unit amplitude")
|
||||
}
|
||||
a32 := math.Sqrt(3)
|
||||
want32 := (1 + a32) * math.Exp(-a32)
|
||||
if math.Abs(kernel.Covariance([]float64{lengthScale}, []float64{0})-want32) > 1e-12 {
|
||||
t.Fatalf("Matern 3/2 at r = L: %.15g, want %.15g",
|
||||
kernel.Covariance([]float64{lengthScale}, []float64{0}), want32)
|
||||
}
|
||||
a52 := math.Sqrt(5)
|
||||
want52 := (1 + a52 + a52*a52/3) * math.Exp(-a52)
|
||||
if math.Abs(m52.Covariance([]float64{lengthScale}, []float64{0})-want52) > 1e-12 {
|
||||
t.Fatalf("Matern 5/2 at r = L: %.15g, want %.15g",
|
||||
m52.Covariance([]float64{lengthScale}, []float64{0}), want52)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeriodicKernelRepeats pins the period: a full period apart the
|
||||
// kernel returns to 1, half a period apart with a short length scale
|
||||
// it has decorrelated to rounding dust.
|
||||
func TestPeriodicKernelRepeats(t *testing.T) {
|
||||
kernel, err := PeriodicKernel(0.2, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("PeriodicKernel: %v", err)
|
||||
}
|
||||
if got := kernel.Covariance([]float64{7}, []float64{7 + 3}); math.Abs(got-1) > 1e-12 {
|
||||
t.Fatalf("one period apart the covariance is %.15g, want 1", got)
|
||||
}
|
||||
if got := kernel.Covariance([]float64{0}, []float64{1.5}); got > 1e-6 {
|
||||
t.Fatalf("half a period apart with a short scale the covariance is %.3g", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarginalLogLikelihoodPrefersTrueHyperparameters draws one path
|
||||
// of a Gaussian process with known hyperparameters over the house
|
||||
// generator and requires the marginal likelihood to rank the true pair
|
||||
// above badly mismatched ones, with a margin far beyond the rounding.
|
||||
func TestMarginalLogLikelihoodPrefersTrueHyperparameters(t *testing.T) {
|
||||
g := core.NewGenerator(53)
|
||||
const n = 30
|
||||
const trueScale = 1.0
|
||||
const trueNoise = 0.05
|
||||
train := core.New(core.Float, n, 1)
|
||||
for i := range n {
|
||||
train.RawFloats()[i] = 5 * float64(i) / float64(n-1)
|
||||
}
|
||||
// The path: one multivariate normal draw over the grid under the
|
||||
// true kernel, plus the observation noise.
|
||||
trueKernel, err := SquaredExponentialKernel(trueScale)
|
||||
if err != nil {
|
||||
t.Fatalf("SquaredExponentialKernel: %v", err)
|
||||
}
|
||||
cov := core.New(core.Float, n, n)
|
||||
for i := range n {
|
||||
for j := range n {
|
||||
cov.RawFloats()[i*n+j] = trueKernel.Covariance(train.RawFloats()[i:i+1], train.RawFloats()[j:j+1])
|
||||
}
|
||||
// A hair of jitter on the diagonal: the Gram matrix of a long
|
||||
// length scale is positive definite by a margin that float64
|
||||
// rounding can eat on the way down, and the draw is fixture
|
||||
// construction, not the model, whose noise variance is fitted
|
||||
// separately below.
|
||||
cov.RawFloats()[i*n+i] += 1e-9
|
||||
}
|
||||
paths, err := MultivariateNormalDraws(g, 1, core.New(core.Float, n), cov)
|
||||
if err != nil {
|
||||
t.Fatalf("MultivariateNormalDraws: %v", err)
|
||||
}
|
||||
y := core.New(core.Float, n)
|
||||
for i := range n {
|
||||
y.RawFloats()[i] = paths.FloatAt(i) + trueNoise*g.NormalUnit()
|
||||
}
|
||||
truth, err := MarginalLogLikelihood(trueKernel, train, y, trueNoise)
|
||||
if err != nil {
|
||||
t.Fatalf("MarginalLogLikelihood: %v", err)
|
||||
}
|
||||
mismatched := []struct {
|
||||
scale float64
|
||||
noise float64
|
||||
}{
|
||||
{0.05, 5},
|
||||
{10, 1e-4},
|
||||
}
|
||||
for _, pair := range mismatched {
|
||||
wrongKernel, kerr := SquaredExponentialKernel(pair.scale)
|
||||
if kerr != nil {
|
||||
t.Fatalf("SquaredExponentialKernel(%g): %v", pair.scale, kerr)
|
||||
}
|
||||
wrong, err := MarginalLogLikelihood(wrongKernel, train, y, pair.noise)
|
||||
if err != nil {
|
||||
t.Fatalf("MarginalLogLikelihood at scale %g: %v", pair.scale, err)
|
||||
}
|
||||
if truth < wrong+5 {
|
||||
t.Fatalf("the true pair scored %.4g against (%g, %g) at %.4g, the margin is under 5",
|
||||
truth, pair.scale, pair.noise, wrong)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGPRegressionMultiDimensional fits a three-dimensional input and
|
||||
// requires the same exact interpolation, the path the Euclidean
|
||||
// distance of every kernel takes over columns.
|
||||
func TestGPRegressionMultiDimensional(t *testing.T) {
|
||||
g := core.NewGenerator(59)
|
||||
const n = 8
|
||||
train := core.New(core.Float, n, 3)
|
||||
y := core.New(core.Float, n)
|
||||
for i := range n {
|
||||
for j := range 3 {
|
||||
train.RawFloats()[i*3+j] = g.Unit()
|
||||
}
|
||||
y.RawFloats()[i] = g.NormalUnit()
|
||||
}
|
||||
kernel, err := Matern52Kernel(1.5)
|
||||
if err != nil {
|
||||
t.Fatalf("Matern52Kernel: %v", err)
|
||||
}
|
||||
res, err := GaussianProcessRegression(kernel, train, y, 0, train)
|
||||
if err != nil {
|
||||
t.Fatalf("GaussianProcessRegression: %v", err)
|
||||
}
|
||||
for i := range n {
|
||||
if math.Abs(res.Mean[i]-y.FloatAt(i)) > 1e-8 {
|
||||
t.Fatalf("point %d: mean %.10g against the observation %.10g", i, res.Mean[i], y.FloatAt(i))
|
||||
}
|
||||
if res.Variance[i] > 1e-10 {
|
||||
t.Fatalf("point %d: variance %.3g at a training point", i, res.Variance[i])
|
||||
}
|
||||
}
|
||||
// The posterior covariance is symmetric by construction; the
|
||||
// mirror must hold.
|
||||
for i := range n {
|
||||
for j := range n {
|
||||
if res.Covariance[i*n+j] != res.Covariance[j*n+i] {
|
||||
t.Fatalf("the posterior covariance is not symmetric at (%d, %d)", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGPValidationAndKernelRefusals checks the constructors and the
|
||||
// entry points refuse what they must, including the singular Gram
|
||||
// matrix that duplicated noiseless rows name.
|
||||
func TestGPValidationAndKernelRefusals(t *testing.T) {
|
||||
kernel, kerr := SquaredExponentialKernel(1)
|
||||
if kerr != nil {
|
||||
t.Fatalf("SquaredExponentialKernel: %v", kerr)
|
||||
}
|
||||
train := mustFloats(t, []float64{0, 1, 2}, 3, 1)
|
||||
y := mustFloats(t, []float64{1, -1, 0.5}, 3)
|
||||
test := mustFloats(t, []float64{0.5}, 1, 1)
|
||||
|
||||
for _, scale := range []float64{0, -1, math.Inf(1), math.NaN()} {
|
||||
if _, err := SquaredExponentialKernel(scale); err == nil {
|
||||
t.Fatalf("squared exponential accepted the scale %v", scale)
|
||||
}
|
||||
if _, err := Matern32Kernel(scale); err == nil {
|
||||
t.Fatalf("Matern 3/2 accepted the scale %v", scale)
|
||||
}
|
||||
if _, err := Matern52Kernel(scale); err == nil {
|
||||
t.Fatalf("Matern 5/2 accepted the scale %v", scale)
|
||||
}
|
||||
if _, err := PeriodicKernel(scale, 1); err == nil {
|
||||
t.Fatalf("periodic kernel accepted the length scale %v", scale)
|
||||
}
|
||||
if _, err := PeriodicKernel(1, scale); err == nil {
|
||||
t.Fatalf("periodic kernel accepted the period %v", scale)
|
||||
}
|
||||
}
|
||||
if _, err := GaussianProcessRegression(nil, train, y, 0, test); err == nil || !strings.Contains(err.Error(), "kernel is nil") {
|
||||
t.Fatalf("nil kernel: got %v, want the nil-kernel refusal", err)
|
||||
}
|
||||
for _, noise := range []float64{-0.1, math.Inf(-1), math.NaN()} {
|
||||
if _, err := GaussianProcessRegression(kernel, train, y, noise, test); err == nil || !strings.Contains(err.Error(), "noise variance") {
|
||||
t.Fatalf("noise variance %v: got %v, want the noise-variance refusal", noise, err)
|
||||
}
|
||||
if _, err := MarginalLogLikelihood(kernel, train, y, noise); err == nil || !strings.Contains(err.Error(), "noise variance") {
|
||||
t.Fatalf("marginal likelihood, noise variance %v: got %v, want the noise-variance refusal", noise, err)
|
||||
}
|
||||
}
|
||||
if _, err := GaussianProcessRegression(kernel, core.New(core.Float, 3), y, 0, test); err == nil || !strings.Contains(err.Error(), "must be rank 2") {
|
||||
t.Fatalf("rank-1 training design: got %v, want the rank refusal", err)
|
||||
}
|
||||
if _, err := GaussianProcessRegression(kernel, train, core.New(core.Float, 3, 1), 0, test); err == nil || !strings.Contains(err.Error(), "must be rank 1") {
|
||||
t.Fatalf("rank-2 response: got %v, want the rank refusal", err)
|
||||
}
|
||||
if _, err := GaussianProcessRegression(kernel, train, mustFloats(t, []float64{1, -1}, 2), 0, test); err == nil || !strings.Contains(err.Error(), "rows but the response") {
|
||||
t.Fatalf("short response: got %v, want the length refusal", err)
|
||||
}
|
||||
if _, err := GaussianProcessRegression(kernel, train, y, 0, core.New(core.Float, 2)); err == nil || !strings.Contains(err.Error(), "must be rank 2") {
|
||||
t.Fatalf("rank-1 test design: got %v, want the rank refusal", err)
|
||||
}
|
||||
wide := mustFloats(t, []float64{0.1, 0.2, 0.3, 0.4}, 2, 2)
|
||||
if _, err := GaussianProcessRegression(kernel, train, y, 0, wide); err == nil || !strings.Contains(err.Error(), "wide but the test design") {
|
||||
t.Fatalf("test design of the wrong width: got %v, want the width refusal", err)
|
||||
}
|
||||
sick := core.New(core.Float, 3, 1)
|
||||
sick.RawFloats()[2] = math.NaN()
|
||||
if _, err := GaussianProcessRegression(kernel, sick, y, 0, test); err == nil || !strings.Contains(err.Error(), "non-finite") {
|
||||
t.Fatalf("non-finite training design: got %v, want the non-finite refusal", err)
|
||||
}
|
||||
if _, err := GaussianProcessRegression(kernel, train, mustFloats(t, []float64{1, -1, math.Inf(1)}, 3), 0, test); err == nil || !strings.Contains(err.Error(), "non-finite") {
|
||||
t.Fatalf("non-finite response: got %v, want the non-finite refusal", err)
|
||||
}
|
||||
// Duplicated rows with no noise: the Gram matrix is singular and
|
||||
// the refusal names the row.
|
||||
dup := mustFloats(t, []float64{1, 1, 2}, 3, 1)
|
||||
_, err := GaussianProcessRegression(kernel, dup, y, 0, test)
|
||||
if err == nil || !strings.Contains(err.Error(), "positive definite") {
|
||||
t.Fatalf("duplicated noiseless rows: got %v, want a positive-definiteness refusal", err)
|
||||
}
|
||||
// A zero-column design has nothing to evaluate.
|
||||
if _, err := GaussianProcessRegression(kernel, core.New(core.Float, 3, 0), y, 0, test); err == nil || !strings.Contains(err.Error(), "at least one column") {
|
||||
t.Fatalf("zero-column design: got %v, want the column refusal", err)
|
||||
}
|
||||
if _, err := GaussianProcessRegression(kernel, core.New(core.Complex, 3, 1), y, 0, test); err == nil || !strings.Contains(err.Error(), "complex") {
|
||||
t.Fatalf("complex training design: got %v, want the complex refusal", err)
|
||||
}
|
||||
if _, err := GaussianProcessRegression(kernel, train, core.New(core.Complex, 3), 0, test); err == nil || !strings.Contains(err.Error(), "complex") {
|
||||
t.Fatalf("complex response: got %v, want the complex refusal", err)
|
||||
}
|
||||
// Integer inputs take the widening accessor path end to end:
|
||||
// design, response and the likelihood assembly.
|
||||
intTrain, ierr := core.FromInts([]int64{0, 10, 20}, 3, 1)
|
||||
if ierr != nil {
|
||||
t.Fatalf("FromInts: %v", ierr)
|
||||
}
|
||||
intY, ierr := core.FromInts([]int64{1, -2, 4}, 3)
|
||||
if ierr != nil {
|
||||
t.Fatalf("FromInts: %v", ierr)
|
||||
}
|
||||
intRes, err := GaussianProcessRegression(kernel, intTrain, intY, 0, intTrain)
|
||||
if err != nil {
|
||||
t.Fatalf("GaussianProcessRegression over int inputs: %v", err)
|
||||
}
|
||||
for i := range 3 {
|
||||
if math.Abs(intRes.Mean[i]-intY.FloatAt(i)) > 1e-8 {
|
||||
t.Fatalf("int input point %d: mean %.10g against the observation %.10g",
|
||||
i, intRes.Mean[i], intY.FloatAt(i))
|
||||
}
|
||||
}
|
||||
intMLL, err := MarginalLogLikelihood(kernel, intTrain, intY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("MarginalLogLikelihood over int inputs: %v", err)
|
||||
}
|
||||
if intMLL != intRes.LogLikelihood {
|
||||
t.Fatalf("the int-input likelihoods disagree: %.17g against %.17g", intRes.LogLikelihood, intMLL)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user