188 lines
5.9 KiB
Go
188 lines
5.9 KiB
Go
// 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")
|
|||
|
|
}
|
|||
|
|
}
|