Files
tensor/stats/mixed_test.go
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

485 lines
15 KiB
Go
Raw Permalink 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"
)
// 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)
}
}