// Copyright (c) 2026 Petr Balvín (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) } }