// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package stats import ( "math" "testing" "sourcedock.dev/petrbalvin/tensor/internal/core" ) // The mixed model against referents. The balanced one-way random // effects model has a closed-form REML answer, the analysis of // variance estimators, so the sweep's optimum is compared against // figures computed from the raw data rather than quoted; the rest // pins the recovery of known effects, the refusal surface and the // determinism of the whole pipeline. // mixedNoise is a deterministic stand-in for measurement noise: a // bounded, aperiodic wiggle no sweep can mistake for structure. func mixedNoise(i int) float64 { return 0.3*math.Sin(7.3*float64(i)+1.1)*math.Cos(2.1*float64(i)) + 0.1*math.Sin(0.7*float64(i)) } // mixedJitter is deterministic white jitter on [−1, 1): the xorshift // finaliser of the house generator's mixing constants, run on the row // index. Unlike the smooth wiggle above it cannot be absorbed by a // within-group linear span, which is what the random-slope fit needs // its residual scale to be. func mixedJitter(i int) float64 { z := uint64(i)*2685821657736338717 + 1 z ^= z >> 13 z ^= z << 7 z ^= z >> 17 return float64(z>>11)/(1<<52)*2 - 1 } func mixedVec(t *testing.T, vals []float64, shape ...int) *core.Array { t.Helper() a, err := core.FromFloats(vals, shape...) if err != nil { t.Fatal(err) } return a } func TestMixedModelBalancedOneWayANOVAReferent(t *testing.T) { // The balanced one-way random effects model: y_ig = μ + b_g + ε_ig // with k observations in each of m groups. REML's optimum is the // ANOVA answer: σ̂²_e = MSW and σ̂²_b = (MSB − MSW)/k, and the // intercept's GLS variance is (k·σ²_b + σ²_e)/(mk). const ( mGroups = 8 k = 6 ) y := make([]float64, 0, mGroups*k) for g := range mGroups { effect := 2.0 * math.Sin(1.7*float64(g)+0.4) // the drawn b_g for i := range k { y = append(y, 5+effect+mixedNoise(g*k+i)) } } groups := make([]int, mGroups*k) for g := range mGroups { for i := range k { groups[g*k+i] = g } } res, err := LinearMixedModel( mixedVec(t, y, len(y)), mixedVec(t, ones(len(y)), len(y), 1), mixedVec(t, ones(len(y)), len(y), 1), groups) if err != nil { t.Fatal(err) } if !res.Converged { t.Fatalf("the balanced fit did not converge (%d iterations)", res.Iterations) } // MSW and MSB from the raw data. grand := 0.0 groupMeans := make([]float64, mGroups) for g := range mGroups { s := 0.0 for i := range k { s += y[g*k+i] } groupMeans[g] = s / float64(k) grand += groupMeans[g] } grand /= float64(mGroups) msw := 0.0 for g := range mGroups { for i := range k { d := y[g*k+i] - groupMeans[g] msw += d * d } } msw /= float64(mGroups * (k - 1)) msb := 0.0 for g := range mGroups { d := groupMeans[g] - grand msb += d * d } msb *= float64(k) / float64(mGroups-1) wantWithin := math.Max((msb-msw)/float64(k), 0) if math.Abs(res.ResidualVariance-msw) > 0.02*msw { t.Fatalf("σ̂²_e = %.6f, want the MSW %.6f", res.ResidualVariance, msw) } if math.Abs(res.RandomCovariance[0]-wantWithin) > 0.05*wantWithin { t.Fatalf("σ̂²_b = %.6f, want the ANOVA answer %.6f", res.RandomCovariance[0], wantWithin) } if math.Abs(res.Coefficients[0]-grand) > 1e-6 { t.Fatalf("μ̂ = %.8f, want the grand mean %.8f", res.Coefficients[0], grand) } wantVar := (float64(k)*res.RandomCovariance[0] + res.ResidualVariance) / float64(mGroups*k) if se := res.StandardErrors[0]; math.Abs(se*se-wantVar) > 1e-9*math.Max(1, wantVar) { t.Fatalf("SE² = %.10f, want the GLS variance %.10f", se*se, wantVar) } // The conditional fitted values reproduce the group means plus the // shrinkage the model applies; the residuals must complement them // to the response. for i := range len(y) { if math.Abs(res.Fitted[i]+res.Residuals[i]-y[i]) > 1e-9 { t.Fatalf("row %d: fitted + residuals = %g, want %g", i, res.Fitted[i]+res.Residuals[i], y[i]) } } } // ones returns n constant 1 values, the intercept column. func ones(n int) []float64 { out := make([]float64, n) for i := range out { out[i] = 1 } return out } func TestMixedModelRandomSlopeRecovery(t *testing.T) { // Fixed effects of 1 and 2 with a per-group random slope, built by // hand so the truth is known exactly. The random design carries // the covariate alone: with the intercept column beside it the // random span covers the fixed design and the REML surface loses // its interior optimum to a ridge of singular covariance (pinned // by the divergence test below). The slope values are centred, so // the fixed part of the truth is exactly (1, 2). slopeValues := []float64{0.9, -1.1, 1.9, -0.3, -1.8, 0.4} groups := make([]int, 0, 36) xs := make([]float64, 0, 36) y := make([]float64, 0, 36) jitter := make([]float64, 0, 36) for g := range slopeValues { for _, x := range []float64{-1, -0.6, -0.2, 0.2, 0.6, 1} { groups = append(groups, g) xs = append(xs, x) jitter = append(jitter, mixedJitter(len(xs)-1)) } } // The jitter is centred on its own sample: the fixed part of the // truth must stay exactly (1, 2), and a noise vector with a mean // would tilt the intercept instead of testing the recovery. mean := 0.0 for _, j := range jitter { mean += j } mean /= float64(len(jitter)) for g, s := range slopeValues { for k := range 6 { i := g*6 + k x := xs[i] y = append(y, 1+2*x+0.5*s*x+0.1*(jitter[i]-mean)) } } design := make([]float64, 0, 2*len(xs)) for _, x := range xs { design = append(design, 1, x) } res, err := LinearMixedModel( mixedVec(t, y, len(y)), mixedVec(t, design, len(xs), 2), mixedVec(t, xs, len(xs), 1), groups) if err != nil { t.Fatal(err) } if !res.Converged { t.Fatalf("the slope fit did not converge (%d iterations)", res.Iterations) } if math.Abs(res.Coefficients[0]-1) > 0.05 || math.Abs(res.Coefficients[1]-2) > 0.05 { t.Fatalf("β̂ = (%.4f, %.4f), want (1, 2)", res.Coefficients[0], res.Coefficients[1]) } // The random slopes must rank with the true ones, and the group // labels must come back in first-appearance order. if len(res.GroupLabels) != 6 { t.Fatalf("group labels %v, want six groups", res.GroupLabels) } strongest := 0 weakest := 0 for g, s := range slopeValues { if s > slopeValues[strongest] { strongest = g } if s < slopeValues[weakest] { weakest = g } } if !(res.RandomEffects[weakest][0] < res.RandomEffects[strongest][0]) { t.Fatalf("the random slopes do not rank with the truth (%v against %v)", res.RandomEffects[weakest][0], slopeValues[strongest]) } for _, se := range res.StandardErrors { if !(se > 0) || math.IsInf(se, 0) { t.Fatalf("the standard error %g is not finite and positive", se) } } } func TestMixedModelDivergenceRefusal(t *testing.T) { // A random design that spans the fixed one under an unstructured // covariance: the intercept and slope of every group absorb what // the fixed effects name, and the REML surface climbs a ridge of // singular Σ without a summit. The fit refuses with the condition // named instead of publishing the climb. groups := []int{0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2} xs := []float64{-1, -0.5, 0.5, 1, -1, -0.5, 0.5, 1, -1, -0.5, 0.5, 1} y := make([]float64, len(groups)) for i, g := range groups { y[i] = 1 + 2*xs[i] + 0.4*float64(g)*xs[i] + float64(g) + 0.1*mixedNoise(i) } design := make([]float64, 0, 2*len(xs)) for _, x := range xs { design = append(design, 1, x) } _, err := LinearMixedModel( mixedVec(t, y, len(y)), mixedVec(t, design, len(xs), 2), mixedVec(t, design, len(xs), 2), groups) if err == nil { t.Fatal("a saturated random design was accepted") } } func TestMixedModelMatchesOLSWithoutRandomEffects(t *testing.T) { // When the groups carry no shared signal the components collapse // towards zero and the fit must land on the plain least squares // answer. xs := make([]float64, 18) y := make([]float64, 18) for i := range 18 { x := -1 + 2*float64(i)/17 xs[i] = x y[i] = 3 - x + 0.4*mixedNoise(i) } groups := make([]int, 18) for i := range 18 { groups[i] = i % 6 } design := make([]float64, 0, 36) for _, x := range xs { design = append(design, 1, x) } res, err := LinearMixedModel( mixedVec(t, y, len(y)), mixedVec(t, design, len(xs), 2), mixedVec(t, design, len(xs), 2), groups) if err != nil { t.Fatal(err) } ref, rerr := LinearRegression(mixedVec(t, design, len(xs), 2), mixedVec(t, y, len(y))) if rerr != nil { t.Fatal(rerr) } for j := range 2 { if math.Abs(res.Coefficients[j]-ref.Coefficients[j]) > 0.01 { t.Fatalf("coefficient %d = %.6f, want the OLS %.6f", j, res.Coefficients[j], ref.Coefficients[j]) } } if res.RandomCovariance[0] > 0.01 { t.Fatalf("Σ̂ = %g on a group-free sample, want a collapsed component", res.RandomCovariance[0]) } } func TestMixedModelDeterministic(t *testing.T) { y := make([]float64, 16) groups := make([]int, 16) for i := range 16 { y[i] = 2 + 0.9*math.Sin(float64(i%4)) + mixedNoise(i) groups[i] = i / 4 } run := func() *LinearMixedModelResult { res, err := LinearMixedModel( mixedVec(t, y, len(y)), mixedVec(t, ones(len(y)), len(y), 1), mixedVec(t, ones(len(y)), len(y), 1), groups) if err != nil { t.Fatal(err) } return res } a, b := run(), run() if a.LogLikelihood != b.LogLikelihood || a.ResidualVariance != b.ResidualVariance || a.RandomCovariance[0] != b.RandomCovariance[0] || a.Iterations != b.Iterations { t.Fatal("two identical fits disagreed") } for i := range a.Fitted { if a.Fitted[i] != b.Fitted[i] { t.Fatalf("row %d: fitted %g against %g", i, a.Fitted[i], b.Fitted[i]) } } } func TestMixedModelShuffledLabels(t *testing.T) { // Labels out of order and with gaps must canonicalise by first // appearance, and permuting the rows with their labels must leave // the fitted components where they started. y := []float64{1.0, 1.2, 3.0, 3.1, 5.2, 5.1, 1.1, 3.2, 5.0, 1.3} groups := []int{7, 7, 3, 3, 5, 5, 7, 3, 5, 7} res, err := LinearMixedModel( mixedVec(t, y, len(y)), mixedVec(t, ones(len(y)), len(y), 1), mixedVec(t, ones(len(y)), len(y), 1), groups) if err != nil { t.Fatal(err) } want := []int{7, 3, 5} for i, label := range res.GroupLabels { if label != want[i] { t.Fatalf("group labels %v, want %v", res.GroupLabels, want) } } if !(res.RandomEffects[0][0] < res.RandomEffects[1][0] && res.RandomEffects[1][0] < res.RandomEffects[2][0]) { t.Fatalf("the random effects %v do not rank with the group means", res.RandomEffects) } } func TestMixedModelRefusals(t *testing.T) { y := mixedVec(t, []float64{1, 2, 3, 4}, 4) x := mixedVec(t, []float64{1, 1, 1, 1}, 4, 1) z := mixedVec(t, []float64{1, 1, 1, 1}, 4, 1) groups := []int{0, 0, 1, 1} if _, err := LinearMixedModel(mixedVec(t, []float64{1, 2}, 2, 1), x, z, groups); err == nil { t.Fatal("a rank-2 response was accepted") } if _, err := LinearMixedModel(mixedVec(t, []float64{1, 2, 3, 4, 5}, 5), x, z, groups); err == nil { t.Fatal("a row count mismatch was accepted") } if _, err := LinearMixedModel(y, mixedVec(t, []float64{1, 1, 1, 1}, 4, 1), mixedVec(t, []float64{1, 1, 1, 1}, 4, 1), []int{0, 1, 2}); err == nil { t.Fatal("a short label vector was accepted") } if _, err := LinearMixedModel(y, x, z, []int{0, 0, 1, -2}); err == nil { t.Fatal("a negative label was accepted") } // More coefficients than observations. big := mixedVec(t, []float64{1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1}, 4, 3) if _, err := LinearMixedModel(y, big, z, groups); err == nil { t.Fatal("a saturated design was accepted") } // A singular fixed design: two identical columns. sing := mixedVec(t, []float64{1, 1, 1, 1, 2, 2, 2, 2}, 4, 2) if _, err := LinearMixedModel(y, sing, z, groups); err == nil { t.Fatal("a collinear fixed design was accepted") } // A single observation carries no fit. if _, err := LinearMixedModel(mixedVec(t, []float64{1}, 1), mixedVec(t, []float64{1}, 1, 1), mixedVec(t, []float64{1}, 1, 1), []int{0}); err == nil { t.Fatal("a one-row fit was accepted") } } // cholSolveInTest factors a symmetric positive definite matrix by its // own Cholesky and solves against one right-hand side, an independent // route the REML referent below evaluates its pieces through. func cholSolveInTest(v []float64, rhs []float64, m int) (logDet float64, solution []float64) { l := make([]float64, m*m) for i := range m { for j := range i + 1 { s := v[i*m+j] for k := range j { s -= l[i*m+k] * l[j*m+k] } if i == j { l[i*m+j] = math.Sqrt(s) } else { l[i*m+j] = s / l[j*m+j] } } logDet += 2 * math.Log(l[i*m+i]) } x := append([]float64(nil), rhs...) for i := range m { s := x[i] for k := range i { s -= l[i*m+k] * x[k] } x[i] = s / l[i*m+i] } for i := m - 1; i >= 0; i-- { s := x[i] for k := i + 1; k < m; k++ { s -= l[k*m+i] * x[k] } x[i] = s / l[i*m+i] } return logDet, x } func TestMixedModelREMLLogLikelihoodReferent(t *testing.T) { // The reported LogLikelihood is checked against an independent // evaluation of the REML criterion at the fitted components, // assembled from first principles in this test: // -2·logL = Σ_g log|V_g| + rᵀV⁻¹r + log|XᵀV⁻¹X| + (n−p)·ln 2π, // with V_g = Z_gΣZ_gᵀ + σ²I and r the fixed-part residual. The // one-way balanced design keeps V_g compound symmetric, so the // pieces are small and the route shares no arithmetic with the fit. const ( mGroups = 6 k = 4 ) y := make([]float64, 0, mGroups*k) groups := make([]int, mGroups*k) for g := range mGroups { effect := 1.5 * math.Sin(0.9*float64(g)+0.2) for i := range k { y = append(y, 3+effect+mixedNoise(g*k+i)) groups[g*k+i] = g } } res, err := LinearMixedModel( mixedVec(t, y, len(y)), mixedVec(t, ones(len(y)), len(y), 1), mixedVec(t, ones(len(y)), len(y), 1), groups) if err != nil { t.Fatal(err) } if !res.Converged { t.Fatalf("the fit did not converge (%d iterations)", res.Iterations) } sigma2 := res.ResidualVariance tau2 := res.RandomCovariance[0] beta := res.Coefficients[0] n := len(y) p := 1 logDetV := 0.0 quad := 0.0 xtvix := 0.0 for g := range mGroups { m := k v := make([]float64, m*m) for i := range m { for j := range m { v[i*m+j] = tau2 } v[i*m+i] += sigma2 } r := make([]float64, m) for i := range m { r[i] = y[g*k+i] - beta } ld, u := cholSolveInTest(v, r, m) logDetV += ld for i := range m { quad += r[i] * u[i] } onesRHS := make([]float64, m) for i := range onesRHS { onesRHS[i] = 1 } _, w := cholSolveInTest(v, onesRHS, m) for i := range m { xtvix += w[i] } } want := -0.5 * (logDetV + quad + math.Log(xtvix) + float64(n-p)*math.Log(2*math.Pi)) if math.Abs(res.LogLikelihood-want) > 1e-8*(1+math.Abs(want)) { t.Fatalf("LogLikelihood = %.10f, want the independent REML %.10f (difference %.3e)", res.LogLikelihood, want, res.LogLikelihood-want) } }