Files
tensor/stats/regression_test.go
T
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

188 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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")
}
}