Files
tensor/stats/lasso_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

582 lines
20 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"
"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)
}
}