Files

582 lines
20 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"
"slices"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// orthonormalDesign builds an (n, p) design whose columns are
// orthogonal in the (1/n)·XᵀX = I sense: every column has mean zero
// and population standard deviation one. The Gram-Schmidt walk starts
// from the constant vector and throws that direction away again: it
// stands for the intercept the design's columns must be orthogonal
// to, and what is left is exactly the space the slopes live in.
func orthonormalDesign(t *testing.T, n, p int, seed int64) *core.Array {
t.Helper()
g := core.NewGenerator(seed)
cols := make([][]float64, 0, p+1)
for j := range p + 1 {
v := make([]float64, n)
if j == 0 {
for i := range n {
v[i] = 1
}
} else {
for i := range n {
v[i] = g.NormalUnit()
}
}
for _, c := range cols {
dot := 0.0
for i := range n {
dot += v[i] * c[i]
}
for i := range n {
v[i] -= dot / float64(n) * c[i]
}
}
norm := 0.0
for i := range n {
norm += v[i] * v[i]
}
factor := math.Sqrt(float64(n) / norm)
for i := range n {
v[i] *= factor
}
cols = append(cols, v)
}
cols = cols[1:]
vals := make([]float64, 0, n*p)
for i := range n {
for j := range p {
vals = append(vals, cols[j][i])
}
}
return mustFromFloats(t, vals, n, p)
}
// TestLassoOrthonormalClosedForm pins the coordinate descent against
// the closed form the orthonormal case admits: the columns decouple,
// and every slope is the soft-thresholded projection of the centred
// response on its own column, sign(ρ)·max(|ρ| − λ, 0), independent of
// the other coordinates and of the iteration.
func TestLassoOrthonormalClosedForm(t *testing.T) {
n, p := 8, 4
design := orthonormalDesign(t, n, p, 5)
// The precondition itself, checked rather than assumed: (1/n)XᵀX
// is the identity and the column means are zero.
for j := range p {
mean := 0.0
for i := range n {
mean += design.FloatAt(i*p + j)
}
if math.Abs(mean/float64(n)) > 1e-12 {
t.Fatalf("column %d has mean %.3g, want 0", j, mean/float64(n))
}
for k := j; k < p; k++ {
dot := 0.0
for i := range n {
dot += design.FloatAt(i*p+j) * design.FloatAt(i*p+k)
}
want := 0.0
if j == k {
want = 1
}
if math.Abs(dot/float64(n)-want) > 1e-12 {
t.Fatalf("columns %d and %d have inner product %.12f, want %.12f", j, k, dot/float64(n), want)
}
}
}
y := mustFromFloats(t, []float64{3, -1, 4, 1, 5, -2, 0, 2}, n)
res, err := Lasso(design, y, 0.6)
if err != nil {
t.Fatalf("Lasso: %v", err)
}
// The closed form, evaluated on the standardised system the fit
// documents in its own result.
yMean := 0.0
for i := range n {
yMean += y.FloatAt(i)
}
yMean /= float64(n)
for j := range p {
mean := res.ColumnMeans[j]
scale := res.ColumnScales[j]
rho := 0.0
for i := range n {
z := (design.FloatAt(i*p+j) - mean) / scale
rho += z * (y.FloatAt(i) - yMean)
}
rho /= float64(n)
// The threshold is spelled inline, not borrowed from
// softThreshold: a helper on both sides of the comparison would
// be wrong together. sign(ρ)·max(|ρ|−λ, 0) is the closed form.
want := 0.0
if rho > 0.6 {
want = rho - 0.6
} else if rho < -0.6 {
want = rho + 0.6
}
if math.Abs(res.Coefficients[j]-want) > 1e-9 {
t.Fatalf("coefficient %d = %.12f, want the soft threshold %.12f", j, res.Coefficients[j], want)
}
}
// The threshold itself, on literals: the shrinkage keeps the sign,
// shrinks by exactly λ and floors at zero.
for _, c := range []struct {
v, lambda, want float64
}{
{0.0, 0.5, 0.0},
{0.25, 0.5, 0.0},
{0.5, 0.5, 0.0},
{1.5, 0.5, 1.0},
{-0.5, 0.5, 0.0},
{-1.5, 0.5, -1.0},
{2.5, 0.5, 2.0},
{-2.5, 0.5, -2.0},
} {
if got := softThreshold(c.v, c.lambda); got != c.want {
t.Fatalf("softThreshold(%g, %g) = %g, want %g", c.v, c.lambda, got, c.want)
}
}
// The intercept keeps the unpenalised identity: the fit passes
// through the column means.
predicted := res.Intercept
for j := range p {
predicted += res.Coefficients[j] * res.ColumnMeans[j]
}
if math.Abs(predicted-yMean) > 1e-12 {
t.Fatalf("the intercept identity broke: %.12g at the column means, want ȳ = %.12g", predicted, yMean)
}
if !res.Converged {
t.Fatalf("the orthonormal fit did not report convergence")
}
}
// TestElasticNetRidgeAgreement pins the alpha = 0 degeneration: with
// the L1 term gone the coordinate descent is solving the Tikhonov
// ridge system (ZᵀZ/n + λI)β = Zᵀ(y − ȳ)/n on the standardised
// design, and the fit must agree with the answer the shared LU solve
// delivers for that system, the same solve LinearRegression runs.
func TestElasticNetRidgeAgreement(t *testing.T) {
const (
n = 40
p = 5
lambda = 0.7
)
g := core.NewGenerator(11)
x := make([]float64, 0, n*p)
yv := make([]float64, 0, n)
betaTrue := []float64{2, -1, 0.5, 0, 3}
for range n {
factor := g.NormalUnit()
row := make([]float64, p)
fitted := 1.0
for j := range p {
row[j] = factor + 0.3*g.NormalUnit()
fitted += betaTrue[j] * row[j]
x = append(x, row[j])
}
yv = append(yv, fitted+0.25*g.NormalUnit())
}
design := mustFromFloats(t, x, n, p)
y := mustFromFloats(t, yv, n)
res, err := ElasticNet(design, y, lambda, 0)
if err != nil {
t.Fatalf("ElasticNet: %v", err)
}
// The standardised system, rebuilt in the test from the
// standardisation the result records.
yMean := 0.0
for _, v := range yv {
yMean += v
}
yMean /= float64(n)
z := make([][]float64, p)
zty := make([]float64, p)
for j := range p {
z[j] = make([]float64, n)
for i := range n {
z[j][i] = (x[i*p+j] - res.ColumnMeans[j]) / res.ColumnScales[j]
zty[j] += z[j][i] * (yv[i] - yMean)
}
zty[j] /= float64(n)
}
normal := make([][]float64, p)
for j := range p {
normal[j] = make([]float64, p)
for k := range p {
for i := range n {
normal[j][k] += z[j][i] * z[k][i]
}
normal[j][k] /= float64(n)
}
normal[j][j] += lambda
}
solved, err := base.SolveSystem("ridgeReference", normal, [][]float64{zty})
if err != nil {
t.Fatalf("the reference ridge solve failed: %v", err)
}
worst := 0.0
for j := range p {
want := solved[0][j] / res.ColumnScales[j]
if math.Abs(res.Coefficients[j]-want) > 1e-7 {
t.Fatalf("ridge coefficient %d = %.10f, want the Tikhonov answer %.10f", j, res.Coefficients[j], want)
}
if d := math.Abs(res.Coefficients[j] - want); d > worst {
worst = d
}
}
wantIntercept := yMean
for j := range p {
wantIntercept -= solved[0][j] / res.ColumnScales[j] * res.ColumnMeans[j]
}
if math.Abs(res.Intercept-wantIntercept) > 1e-7 {
t.Fatalf("ridge intercept = %.10f, want %.10f", res.Intercept, wantIntercept)
}
t.Logf("alpha = 0 agrees with the LU ridge solve to %.3g", worst)
}
// TestLassoPathSameGrid pins the shared path: the alpha = 0 ridge
// walks exactly the lambda grid the pure lasso defines, so the two
// fits are comparable coefficient for coefficient along it.
func TestLassoPathSameGrid(t *testing.T) {
g := core.NewGenerator(3)
const n, p = 30, 4
x := make([]float64, 0, n*p)
yv := make([]float64, 0, n)
for range n {
fitted := 2.0
for j := range p {
v := g.NormalUnit()
fitted += float64(p-j) * v
x = append(x, v)
}
yv = append(yv, fitted+0.5*g.NormalUnit())
}
design := mustFromFloats(t, x, n, p)
y := mustFromFloats(t, yv, n)
lasso, err := LassoPath(design, y, 1)
if err != nil {
t.Fatalf("LassoPath: %v", err)
}
ridge, err := LassoPath(design, y, 0)
if err != nil {
t.Fatalf("LassoPath: %v", err)
}
if !slices.Equal(lasso.Lambdas, ridge.Lambdas) {
t.Fatalf("the ridge path left the lasso grid")
}
// Grid shape: descending, log-spaced, spanning the documented
// three decades.
for k := 1; k < len(lasso.Lambdas); k++ {
if lasso.Lambdas[k] >= lasso.Lambdas[k-1] {
t.Fatalf("the grid is not descending at %d: %g then %g", k, lasso.Lambdas[k-1], lasso.Lambdas[k])
}
}
ratio := lasso.Lambdas[1] / lasso.Lambdas[0]
if math.Abs(ratio-math.Pow(lassoGridRatio, 1.0/float64(lassoGridSteps-1))) > 1e-12 {
t.Fatalf("the grid is not log-spaced: consecutive ratio %.12g", ratio)
}
if math.Abs(lasso.Lambdas[len(lasso.Lambdas)-1]/lasso.Lambdas[0]-lassoGridRatio) > 1e-9 {
t.Fatalf("the grid spans %.6g decades of ratio, want %.6g",
lasso.Lambdas[len(lasso.Lambdas)-1]/lasso.Lambdas[0], lassoGridRatio)
}
}
// TestLassoSparseSupportRecovery recovers the support of a sparse
// true model from seeded generated data: along the path there is a
// lambda interval where the nonzero set is exactly the true one and
// the estimates sit close to the truth, while the top of the grid is
// exactly the all-zero answer it promises.
func TestLassoSparseSupportRecovery(t *testing.T) {
const n, p = 300, 15
g := core.NewGenerator(7)
betaTrue := make([]float64, p)
betaTrue[2], betaTrue[7], betaTrue[11] = 1.5, -2.0, 0.8
x := make([]float64, 0, n*p)
yv := make([]float64, 0, n)
for range n {
fitted := 3.0
for j := range p {
v := g.NormalUnit()
fitted += betaTrue[j] * v
x = append(x, v)
}
yv = append(yv, fitted+0.5*g.NormalUnit())
}
design := mustFromFloats(t, x, n, p)
y := mustFromFloats(t, yv, n)
path, err := LassoPath(design, y, 1)
if err != nil {
t.Fatalf("LassoPath: %v", err)
}
if !path.Converged {
t.Fatalf("the path did not converge within its budget")
}
// The top of the grid: every slope exactly zero, the documented
// meaning of lambdaMax.
for j := range p {
if path.Coefficients[0][j] != 0 {
t.Fatalf("coefficient %d = %g at the top of the grid, want exactly 0", j, path.Coefficients[0][j])
}
}
// Somewhere along the path the support is the true one.
trueSet := []int{2, 7, 11}
recovered := false
for k := range path.Lambdas {
support := []int{}
for j := range p {
if path.Coefficients[k][j] != 0 {
support = append(support, j)
}
}
if !slices.Equal(support, trueSet) {
continue
}
recovered = true
closeEnough := true
for _, j := range trueSet {
if math.Abs(path.Coefficients[k][j]-betaTrue[j]) > 0.2 {
closeEnough = false
}
}
if closeEnough {
t.Logf("support recovered at lambda[%d] = %.4g, coefficients within %.3f of the truth",
k, path.Lambdas[k], 0.2)
break
}
recovered = false
}
if !recovered {
t.Fatalf("no lambda on the path recovered the true support {2, 7, 11}")
}
}
// TestLassoPathWarmStart measures the warm start: the same grid fitted
// cold, one ElasticNet call per lambda from zero every time, must
// spend more coordinate cycles than the warm path, and by the bottom
// of the grid the difference is the whole point of the path.
func TestLassoPathWarmStart(t *testing.T) {
const n, p = 100, 10
g := core.NewGenerator(13)
betaTrue := make([]float64, p)
betaTrue[1], betaTrue[4], betaTrue[8] = 1.2, -1.6, 0.9
x := make([]float64, 0, n*p)
yv := make([]float64, 0, n)
for range n {
factor := g.NormalUnit()
fitted := 1.0
for j := range p {
v := factor + 0.5*g.NormalUnit()
fitted += betaTrue[j] * v
x = append(x, v)
}
yv = append(yv, fitted+0.4*g.NormalUnit())
}
design := mustFromFloats(t, x, n, p)
y := mustFromFloats(t, yv, n)
warm, err := LassoPath(design, y, 1)
if err != nil {
t.Fatalf("LassoPath: %v", err)
}
cold := make([]int, len(warm.Lambdas))
totalWarm, totalCold := 0, 0
for k, lambda := range warm.Lambdas {
fit, err := ElasticNet(design, y, lambda, 1)
if err != nil {
t.Fatalf("ElasticNet: %v", err)
}
cold[k] = fit.Iterations
totalWarm += warm.Iterations[k]
totalCold += cold[k]
if warm.Iterations[k] > cold[k] {
t.Fatalf("the warm start lost to the cold fit at lambda[%d]: %d cycles against %d",
k, warm.Iterations[k], cold[k])
}
}
t.Logf("warm path %d cycles against cold %d; at the smallest lambda %d against %d",
totalWarm, totalCold, warm.Iterations[len(cold)-1], cold[len(cold)-1])
if totalWarm >= totalCold {
t.Fatalf("the warm path spent %d cycles, the cold path only %d", totalWarm, totalCold)
}
if warm.Iterations[len(cold)-1] >= cold[len(cold)-1] {
t.Fatalf("at the smallest lambda the warm start spent %d cycles against the cold %d",
warm.Iterations[len(cold)-1], cold[len(cold)-1])
}
}
// TestLassoDuplicateColumnResolve pins the collinear resolution: two
// identical columns make the optimum non-unique, the effect free to
// sit anywhere on the face the pair spans. The coordinate descent
// settles on that face deterministically, so identical inputs give
// bit-identical coefficients, and the fit is the single-copy answer
// in everything a consumer can measure: the fitted values, the
// summed effect of the pair and the residual structure.
func TestLassoDuplicateColumnResolve(t *testing.T) {
const n = 60
g := core.NewGenerator(17)
x0 := make([]float64, n)
x2 := make([]float64, n)
yv := make([]float64, n)
for i := range n {
x0[i] = g.NormalUnit()
x2[i] = g.NormalUnit()
yv[i] = 1 + 2*x0[i] + 0.5*x2[i] + 0.3*g.NormalUnit()
}
dupVals := make([]float64, 0, 3*n)
singleVals := make([]float64, 0, 2*n)
for i := range n {
dupVals = append(dupVals, x0[i], x0[i], x2[i])
singleVals = append(singleVals, x0[i], x2[i])
}
dup := mustFromFloats(t, dupVals, n, 3)
single := mustFromFloats(t, singleVals, n, 2)
y := mustFromFloats(t, yv, n)
res, err := ElasticNet(dup, y, 0.02, 1)
if err != nil {
t.Fatalf("ElasticNet: %v", err)
}
if !res.Converged {
t.Fatalf("the duplicated design did not converge")
}
for j := range 3 {
if math.IsNaN(res.Coefficients[j]) || math.IsInf(res.Coefficients[j], 0) {
t.Fatalf("coefficient %d diverged to %g", j, res.Coefficients[j])
}
}
// Determinism: a second, identical fit lands on the same bits.
again, err := ElasticNet(dup, y, 0.02, 1)
if err != nil {
t.Fatalf("the repeated ElasticNet failed: %v", err)
}
if !slices.Equal(res.Coefficients, again.Coefficients) {
t.Fatalf("the duplicated design settled differently on a repeat: %v against %v",
res.Coefficients, again.Coefficients)
}
// The degenerate face carries the single-copy effect: the pair
// sums to it, and the third column agrees with its own fit.
ref, err := ElasticNet(single, y, 0.02, 1)
if err != nil {
t.Fatalf("ElasticNet on the single-copy design: %v", err)
}
if math.Abs(res.Coefficients[0]+res.Coefficients[1]-ref.Coefficients[0]) > 1e-6 {
t.Fatalf("the duplicate pair summed to %g, the single copy took %g",
res.Coefficients[0]+res.Coefficients[1], ref.Coefficients[0])
}
if math.Abs(res.Coefficients[2]-ref.Coefficients[1]) > 1e-6 {
t.Fatalf("the independent column moved from %g to %g under the duplicate",
ref.Coefficients[1], res.Coefficients[2])
}
for i := range n {
if math.Abs(res.Fitted[i]-ref.Fitted[i]) > 1e-6 {
t.Fatalf("the duplicate changed fitted value %d: %.10g against %.10g", i, res.Fitted[i], ref.Fitted[i])
}
}
t.Logf("duplicate pair resolved deterministically as (%g, %g), the single-copy effect being %g",
res.Coefficients[0], res.Coefficients[1], ref.Coefficients[0])
}
// TestLassoRefitsSmoke walks the mixed alphas through one small fit
// each, so the whole alpha range shares one code path and none of it
// is only exercised by the pins above.
func TestLassoAlphaRangeSmoke(t *testing.T) {
design := orthonormalDesign(t, 12, 3, 21)
y := mustFromFloats(t, []float64{2, -1, 3, 0, 1, -2, 4, 1, 0, -1, 2, 3}, 12)
for _, alpha := range []float64{0, 0.25, 0.5, 0.75, 1} {
res, err := ElasticNet(design, y, 0.3, alpha)
if err != nil {
t.Fatalf("ElasticNet alpha %g: %v", alpha, err)
}
if !res.Converged {
t.Fatalf("ElasticNet alpha %g did not converge", alpha)
}
for j := range 3 {
if math.IsNaN(res.Coefficients[j]) {
t.Fatalf("ElasticNet alpha %g produced a NaN at %d", alpha, j)
}
}
}
}
// TestLassoOnIntegerArrays exercises the widening accessor's fallback
// paths in the standardisation and the final sweep: an integer design
// and response reach the fit through FloatAt rather than a raw float
// payload, and the answer matches the widened floats exactly.
func TestLassoOnIntegerArrays(t *testing.T) {
design := mustFromInts(t, []int64{0, 0, 1, 1, 2, 0, 3, 1, 4, 0, 5, 1, 6, 0}, 7, 2)
y := mustFromInts(t, []int64{2, 4, 5, 7, 8, 10, 11}, 7)
res, err := Lasso(design, y, 1e-5)
if err != nil {
t.Fatalf("Lasso on integer input: %v", err)
}
if !res.Converged {
t.Fatalf("the integer-input fit did not converge")
}
if math.Abs(res.Coefficients[0]-1.5) > 1e-4 || math.Abs(res.Coefficients[1]-0.5) > 1e-4 {
t.Fatalf("the integer-input fit is (%.6f, %.6f), want (1.5, 0.5)",
res.Coefficients[0], res.Coefficients[1])
}
widened := mustFromFloats(t, []float64{0, 0, 1, 1, 2, 0, 3, 1, 4, 0, 5, 1, 6, 0}, 7, 2)
yw := mustFromFloats(t, []float64{2, 4, 5, 7, 8, 10, 11}, 7)
reference, err := Lasso(widened, yw, 1e-5)
if err != nil {
t.Fatalf("Lasso on the widened floats: %v", err)
}
if !slices.Equal(res.Coefficients, reference.Coefficients) {
t.Fatalf("the integer input fit %v against the float input %v",
res.Coefficients, reference.Coefficients)
}
}
// TestLassoInputValidation refuses every malformed input the fit
// cannot answer, naming the condition in each case.
func TestLassoInputValidation(t *testing.T) {
good := mustFromFloats(t, []float64{1, 2, 3, 4, 5, 6, 7, 8}, 4, 2)
resp := mustFromFloats(t, []float64{1, 2, 3, 4}, 4)
constant := mustFromFloats(t, []float64{1, 1, 1, 1, 1, 2, 1, 3}, 4, 2)
nanY := mustFromFloats(t, []float64{1, 2, math.NaN(), 4}, 4)
if _, err := ElasticNet(mustFromFloats(t, []float64{1, 2, 3, 4}, 4), resp, 0.1, 1); err == nil || !strings.Contains(err.Error(), "must be rank 2") {
t.Fatalf("a rank 1 design: got %v, want the rank refusal", err)
}
if _, err := ElasticNet(good, mustFromFloats(t, []float64{1, 2, 3, 4, 5, 6}, 3, 2), 0.1, 1); err == nil || !strings.Contains(err.Error(), "must be rank 1") {
t.Fatalf("a rank 2 response: got %v, want the rank refusal", err)
}
if _, err := ElasticNet(good, mustFromFloats(t, []float64{1, 2, 3}, 3), 0.1, 1); err == nil || !strings.Contains(err.Error(), "rows but the response") {
t.Fatalf("a row mismatch: got %v, want the length refusal", err)
}
if _, err := ElasticNet(mustFromFloats(t, []float64{1, 2, 3, 4, 5, 6, 7, 8}, 4, 2), nanY, 0.1, 1); err == nil || !strings.Contains(err.Error(), "non-finite") {
t.Fatalf("a non-finite response: got %v, want the non-finite refusal", err)
}
if _, err := ElasticNet(constant, resp, 0.1, 1); err == nil || !strings.Contains(err.Error(), "cannot be standardised") {
t.Fatalf("a constant design column: got %v, want the standardisation refusal", err)
}
if _, err := ElasticNet(good, resp, -0.5, 1); err == nil || !strings.Contains(err.Error(), "lambda") {
t.Fatalf("a negative lambda: got %v, want the lambda refusal", err)
}
if _, err := ElasticNet(good, resp, 0.1, 1.5); err == nil || !strings.Contains(err.Error(), "alpha") {
t.Fatalf("an alpha above 1: got %v, want the alpha refusal", err)
}
if _, err := ElasticNet(good, resp, 0.1, -0.1); err == nil || !strings.Contains(err.Error(), "alpha") {
t.Fatalf("a negative alpha: got %v, want the alpha refusal", err)
}
if _, err := ElasticNet(mustFromFloats(t, []float64{1, 2}, 1, 2), mustFromFloats(t, []float64{1}, 1), 0.1, 1); err == nil || !strings.Contains(err.Error(), "at least two observations") {
t.Fatalf("a single observation: got %v, want the observation floor refusal", err)
}
complexDesign := core.New(core.Complex, 4, 2)
if _, err := ElasticNet(complexDesign, resp, 0.1, 1); err == nil || !strings.Contains(err.Error(), "complex") {
t.Fatalf("complex input: got %v, want the complex refusal", err)
}
if _, err := LassoPath(good, resp, 2); err == nil || !strings.Contains(err.Error(), "alpha") {
t.Fatalf("LassoPath, an alpha above 1: got %v, want the alpha refusal", err)
}
}