Files
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

225 lines
7.2 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"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// TestANOVAOneWayIdenticalMeans pins the null behaviour: two groups
// with the same mean give F = 0 exactly and p = 1 exactly, since the
// between-group sum of squares vanishes.
func TestANOVAOneWayIdenticalMeans(t *testing.T) {
groups := []*core.Array{
mustFloats(t, []float64{1, 2, 3}),
mustFloats(t, []float64{3, 1, 2}),
}
f, p, err := ANOVAOneWay(groups)
if err != nil {
t.Fatalf("ANOVAOneWay: %v", err)
}
if f != 0 {
t.Errorf("F = %v, want 0", f)
}
if p != 1 {
t.Errorf("p = %v, want 1", p)
}
}
// TestANOVAOneWayHandComputed pins a fully hand-computable case:
// groups {1,2,3} and {11,12,13} give SSB = 150, SSW = 4, F = 150 with
// (1, 4) degrees of freedom, and the upper tail has the closed form
// P(F > f) = 1 − 1.5·s + 0.5·s³ with s = √(1 − 2/77), which the test
// evaluates independently of the incomplete-beta continued fraction.
func TestANOVAOneWayHandComputed(t *testing.T) {
f, p, err := ANOVAOneWay([]*core.Array{
mustFloats(t, []float64{1, 2, 3}),
mustFloats(t, []float64{11, 12, 13}),
})
if err != nil {
t.Fatalf("ANOVAOneWay: %v", err)
}
if math.Abs(f-150) > 1e-9 {
t.Errorf("F = %v, want 150", f)
}
s := math.Sqrt(75.0 / 77.0)
want := 1 - 1.5*s + 0.5*s*s*s
if math.Abs(p-want) > 1e-9 {
t.Errorf("p = %.16g, want %.16g", p, want)
}
}
// TestANOVAOneWayThreeGroups pins a three-group case whose p-value
// reduces to elementary arithmetic: F = 3 with (2, 6) degrees of
// freedom has upper tail I_{1/2}(3, 1) = (1/2)³ = 1/8 exactly.
func TestANOVAOneWayThreeGroups(t *testing.T) {
f, p, err := ANOVAOneWay([]*core.Array{
mustFloats(t, []float64{1, 2, 3}),
mustFloats(t, []float64{2, 3, 4}),
mustFloats(t, []float64{3, 4, 5}),
})
if err != nil {
t.Fatalf("ANOVAOneWay: %v", err)
}
if math.Abs(f-3) > 1e-9 {
t.Errorf("F = %v, want 3", f)
}
if math.Abs(p-0.125) > 1e-12 {
t.Errorf("p = %v, want 0.125", p)
}
}
// TestANOVAOneWayDegenerate pins the zero-variance limits: distinct
// means with no noise send F to +Inf with p = 0, while one repeated
// value everywhere is refused instead of answered 0/0.
func TestANOVAOneWayDegenerate(t *testing.T) {
f, p, err := ANOVAOneWay([]*core.Array{
mustFloats(t, []float64{1, 1}),
mustFloats(t, []float64{2, 2}),
})
if err != nil {
t.Fatalf("ANOVAOneWay: %v", err)
}
if !math.IsInf(f, 1) || p != 0 {
t.Errorf("F = %v, p = %v, want +Inf and 0", f, p)
}
_, _, err = ANOVAOneWay([]*core.Array{
mustFloats(t, []float64{5, 5}),
mustFloats(t, []float64{5, 5}),
})
if err == nil || !strings.Contains(err.Error(), "identical value") {
t.Errorf("all-identical observations: error %v, want the degeneracy refusal", err)
}
}
// TestANOVAOneWayRejects pins the input contracts.
func TestANOVAOneWayRejects(t *testing.T) {
ok := mustFloats(t, []float64{1, 2})
cases := []struct {
name string
groups []*core.Array
want string
}{
{"one group", []*core.Array{ok}, "at least two groups"},
{"empty group", []*core.Array{ok, mustFloats(t, nil)}, "empty"},
{"NaN observation", []*core.Array{ok, mustFloats(t, []float64{1, math.NaN()})}, "non-finite"},
{"infinite observation", []*core.Array{ok, mustFloats(t, []float64{1, math.Inf(1)})}, "non-finite"},
{"singletons", []*core.Array{mustFloats(t, []float64{1}), mustFloats(t, []float64{2})}, "no within-group degrees of freedom"},
{"complex group", []*core.Array{ok, mustFromComplexes(t, []complex128{1, 2}, 2)}, "complex"},
}
for _, c := range cases {
if _, _, err := ANOVAOneWay(c.groups); err == nil {
t.Errorf("%s: expected an error", c.name)
} else if !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: error %q lacks %q", c.name, err, c.want)
}
}
}
// TestMannWhitneyUSeparated pins the fully separated, tie-free case
// a = {1,2,3}, b = {4,5,6} against hand arithmetic: every rank goes
// to b, so u = 0; the tie-free variance is σ² = nm(N+1)/12 = 9·7/12 =
// 5.25, and the continuity-corrected z is 4/√5.25.
func TestMannWhitneyUSeparated(t *testing.T) {
u, p, err := MannWhitneyU(mustFloats(t, []float64{1, 2, 3}), mustFloats(t, []float64{4, 5, 6}))
if err != nil {
t.Fatalf("MannWhitneyU: %v", err)
}
if u != 0 {
t.Errorf("u = %v, want 0", u)
}
want := 2 * (1 - NormalCDF(4/math.Sqrt(5.25)))
if math.Abs(p-want) > 1e-12 {
t.Errorf("p = %.16g, want %.16g", p, want)
}
// Independently computed reference (an independent 30-digit calculation).
if math.Abs(p-0.080855598370052291) > 1e-15 {
t.Errorf("p = %.16g, want the tabulated 0.080855598370052291", p)
}
}
// TestMannWhitneyUTies pins the tie-corrected variance on a sample
// pair with two tied blocks of three: u = 3, σ² = 76/7 by hand, and
// the continuity-corrected z is 4.5/√(76/7).
func TestMannWhitneyUTies(t *testing.T) {
u, p, err := MannWhitneyU(mustFloats(t, []float64{1, 2, 2, 3}), mustFloats(t, []float64{2, 3, 3, 4}))
if err != nil {
t.Fatalf("MannWhitneyU: %v", err)
}
if u != 3 {
t.Errorf("u = %v, want 3", u)
}
want := 2 * (1 - NormalCDF(4.5/math.Sqrt(76.0/7.0)))
if math.Abs(p-want) > 1e-12 {
t.Errorf("p = %.16g, want %.16g", p, want)
}
// Independently computed reference (an independent 30-digit calculation).
if math.Abs(p-0.17203370892182298) > 1e-15 {
t.Errorf("p = %.16g, want the tabulated 0.17203370892182298", p)
}
}
// TestMannWhitneyUSymmetry checks the companion statistic and the
// shared p-value: swapping the samples gives n·m − u and the same p.
func TestMannWhitneyUSymmetry(t *testing.T) {
a := mustFloats(t, []float64{1, 2, 2, 3})
b := mustFloats(t, []float64{2, 3, 3, 4})
u1, p1, err := MannWhitneyU(a, b)
if err != nil {
t.Fatalf("MannWhitneyU(a, b): %v", err)
}
u2, p2, err := MannWhitneyU(b, a)
if err != nil {
t.Fatalf("MannWhitneyU(b, a): %v", err)
}
if u1+u2 != 16 {
t.Errorf("u + u' = %v + %v, want 16", u1, u2)
}
if p1 != p2 {
t.Errorf("p-values differ: %v vs %v", p1, p2)
}
}
// TestMannWhitneyUCentral pins the exact-centre behaviour: identical
// samples put u at n·m/2, where the clamped z = 0 gives p = 1.
func TestMannWhitneyUCentral(t *testing.T) {
u, p, err := MannWhitneyU(mustFloats(t, []float64{1, 2, 3, 4}), mustFloats(t, []float64{1, 2, 3, 4}))
if err != nil {
t.Fatalf("MannWhitneyU: %v", err)
}
if u != 8 {
t.Errorf("u = %v, want 8", u)
}
if p != 1 {
t.Errorf("p = %v, want 1", p)
}
}
// TestMannWhitneyURejects pins the input contracts.
func TestMannWhitneyURejects(t *testing.T) {
ok := mustFloats(t, []float64{1, 2})
cases := []struct {
name string
a, b *core.Array
want string
}{
{"empty a", mustFloats(t, nil), ok, "non-empty"},
{"empty b", ok, mustFloats(t, nil), "non-empty"},
{"complex", ok, mustFromComplexes(t, []complex128{1, 2}, 2), "complex"},
{"NaN", ok, mustFloats(t, []float64{1, math.NaN()}), "non-finite"},
{"all tied", mustFloats(t, []float64{1, 1}), mustFloats(t, []float64{1, 1}), "no spread"},
}
for _, c := range cases {
if _, _, err := MannWhitneyU(c.a, c.b); err == nil {
t.Errorf("%s: expected an error", c.name)
} else if !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: error %q lacks %q", c.name, err, c.want)
}
}
}