Files

188 lines
5.9 KiB
Go
Raw Permalink Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package stats
import (
"math"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// mustDesign builds a design matrix from row-major values.
func mustDesign(t *testing.T, vals []float64, rows, cols int) *core.Array {
t.Helper()
a, err := core.FromFloats(vals, rows, cols)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
return a
}
// TestLinearRegressionExactModel pins the inference on a noiseless
// linear model: the coefficients recover, R² is 1, σ̂² is 0 and the
// perfect fit is flagged as such (t infinite, p zero).
func TestLinearRegressionExactModel(t *testing.T) {
// y = 2 + 3x over x = 1..6.
design := mustDesign(t, []float64{
1, 1, 1, 2, 1, 3, 1, 4, 1, 5, 1, 6,
}, 6, 2)
yv := make([]float64, 6)
for i := range 6 {
yv[i] = 2 + 3*float64(i+1)
}
y, err := core.FromFloats(yv, 6)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
res, err := LinearRegression(design, y)
if err != nil {
t.Fatalf("LinearRegression: %v", err)
}
if math.Abs(res.Coefficients[0]-2) > 1e-12 || math.Abs(res.Coefficients[1]-3) > 1e-12 {
t.Fatalf("coefficients = (%.10f, %.10f), want (2, 3)", res.Coefficients[0], res.Coefficients[1])
}
if math.Abs(res.RSquared-1) > 1e-12 {
t.Fatalf("R² = %.12f, want 1", res.RSquared)
}
if res.ResidualVariance > 1e-24 {
t.Fatalf("σ̂² = %.3g, want 0", res.ResidualVariance)
}
if res.FPValue > 1e-12 {
t.Fatalf("F p-value = %.3g, want 0", res.FPValue)
}
}
// TestLinearRegressionNoisy pins the inference against hand-computed
// formulas on a small noisy dataset.
func TestLinearRegressionNoisy(t *testing.T) {
design := mustDesign(t, []float64{
1, 0, 1, 1, 1, 2, 1, 3, 1, 4, 1, 5,
}, 6, 2)
yv := []float64{1.2, 2.1, 2.9, 4.2, 4.8, 6.3}
y, _ := core.FromFloats(yv, 6)
res, err := LinearRegression(design, y)
if err != nil {
t.Fatalf("LinearRegression: %v", err)
}
n, p := 6.0, 2.0
// Reference computations from first principles.
xv := []float64{0, 1, 2, 3, 4, 5}
sx := 0.0
sy := 0.0
sxy := 0.0
sxx := 0.0
for i := range 6 {
sx += xv[i]
sy += yv[i]
sxy += xv[i] * yv[i]
sxx += xv[i] * xv[i]
}
slope := (n*sxy - sx*sy) / (n*sxx - sx*sx)
intercept := (sy - slope*sx) / n
if math.Abs(res.Coefficients[1]-slope) > 1e-12 || math.Abs(res.Coefficients[0]-intercept) > 1e-12 {
t.Fatalf("coefficients = (%.10f, %.10f), want (%.10f, %.10f)",
res.Coefficients[0], res.Coefficients[1], intercept, slope)
}
rss := 0.0
for i := range 6 {
r := yv[i] - (intercept + slope*xv[i])
rss += r * r
if math.Abs(res.Residuals[i]-r) > 1e-12 {
t.Fatalf("residual[%d] = %.10f, want %.10f", i, res.Residuals[i], r)
}
}
if math.Abs(res.ResidualVariance-rss/(n-p)) > 1e-12 {
t.Fatalf("σ̂² = %.10f, want %.10f", res.ResidualVariance, rss/(n-p))
}
// SE of the slope: σ̂·sqrt(1/Sxx_c) with Sxx_c = Σ(x−x̄)².
xbar := sx / n
sxxc := 0.0
for _, v := range xv {
sxxc += (v - xbar) * (v - xbar)
}
seSlope := math.Sqrt(rss/(n-p)) / math.Sqrt(sxxc)
// NaN would slip through a plain tolerance comparison, so it gets
// its own gate before the value check.
if math.IsNaN(res.StandardErrors[1]) || math.IsNaN(res.TStatistics[1]) || math.IsNaN(res.PValues[1]) {
t.Fatalf("non-finite inference: SE=%.6g t=%.6g p=%.6g",
res.StandardErrors[1], res.TStatistics[1], res.PValues[1])
}
if math.Abs(res.StandardErrors[1]-seSlope) > 1e-12 {
t.Fatalf("SE(slope) = %.10f, want %.10f", res.StandardErrors[1], seSlope)
}
if math.Abs(res.TStatistics[1]-slope/seSlope) > 1e-10 {
t.Fatalf("t(slope) = %.10f, want %.10f", res.TStatistics[1], slope/seSlope)
}
// Two-sided p from the t CDF itself (independent re-entry).
pv, err := twoSidedT(res.TStatistics[1], 4)
if err != nil {
t.Fatalf("twoSidedT: %v", err)
}
if math.Abs(res.PValues[1]-pv) > 1e-14 {
t.Fatalf("p(slope) = %.10f, want %.10f", res.PValues[1], pv)
}
// F = t² for the single-slope model.
if math.Abs(res.FStatistic-res.TStatistics[1]*res.TStatistics[1]) > 1e-8 {
t.Fatalf("F = %.8f, want t² = %.8f", res.FStatistic, res.TStatistics[1]*res.TStatistics[1])
}
// F p equals the slope's t p for p = 2.
if math.Abs(res.FPValue-res.PValues[1]) > 1e-10 {
t.Fatalf("F p = %.10f, want %.10f", res.FPValue, res.PValues[1])
}
}
// TestLinearRegressionTwoPredictors pins a multi-column design against
// a brute-force normal-equation solve in the test.
func TestLinearRegressionTwoPredictors(t *testing.T) {
g := core.NewGenerator(17)
const n = 40
vals := make([]float64, n*3)
yv := make([]float64, n)
for i := range n {
a := g.NormalUnit()
b := g.NormalUnit()
vals[i*3] = 1
vals[i*3+1] = a
vals[i*3+2] = b
yv[i] = -1.5 + 2*a - 0.7*b + 0.3*g.NormalUnit()
}
design := mustDesign(t, vals, n, 3)
y, _ := core.FromFloats(yv, n)
res, err := LinearRegression(design, y)
if err != nil {
t.Fatalf("LinearRegression: %v", err)
}
if math.Abs(res.Coefficients[0]+1.5) > 0.2 || math.Abs(res.Coefficients[1]-2) > 0.2 ||
math.Abs(res.Coefficients[2]+0.7) > 0.2 {
t.Fatalf("coefficients = %v, want roughly (-1.5, 2, -0.7)", res.Coefficients)
}
if res.RSquared < 0.9 {
t.Fatalf("R² = %.3f, the model explains more than that", res.RSquared)
}
if res.FPValue > 1e-10 {
t.Fatalf("F p = %.3g, the model is significant", res.FPValue)
}
if res.DModel != 2 || res.DResidual != 37 {
t.Fatalf("degrees of freedom (%d, %d), want (2, 37)", res.DModel, res.DResidual)
}
}
// TestLinearRegressionErrors pins the input gates.
func TestLinearRegressionErrors(t *testing.T) {
x := mustDesign(t, []float64{1, 1, 1, 1}, 2, 2)
y, _ := core.FromFloats([]float64{1, 2}, 2)
if _, err := LinearRegression(x, y); err == nil {
t.Error("n == p accepted")
}
if _, err := LinearRegression(x, y); err == nil {
t.Error("square design accepted")
}
bad := mustDesign(t, []float64{1, 2, 2, 4, 1, 2}, 3, 2)
y3, _ := core.FromFloats([]float64{1, 2, 3}, 3)
if _, err := LinearRegression(bad, y3); err == nil {
t.Error("rank-deficient design accepted")
}
}