153 lines
4.6 KiB
Go
153 lines
4.6 KiB
Go
// 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"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Estimation guards and exactness pins: silent NaN estimates, a
|
||
|
|
// perfect-significance null model, and int64 samples that round
|
||
|
|
// through float64.
|
||
|
|
|
||
|
|
// TestCovarianceMatrixRejectsNaN: one NaN observation flowed into the
|
||
|
|
// column means and produced an all-NaN matrix with a nil error.
|
||
|
|
func TestCovarianceMatrixRejectsNaN(t *testing.T) {
|
||
|
|
obs, err := core.FromFloats([]float64{1, 2, math.NaN(), 4, 5, 6}, 3, 2)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err := CovarianceMatrix(obs); err == nil || !strings.Contains(err.Error(), "CovarianceMatrix") {
|
||
|
|
t.Fatalf("CovarianceMatrix on a NaN observation: err = %v", err)
|
||
|
|
}
|
||
|
|
if _, err := CorrelationMatrix(obs); err == nil || !strings.Contains(err.Error(), "CorrelationMatrix") {
|
||
|
|
t.Fatalf("CorrelationMatrix on a NaN observation: err = %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestRegressionInterceptOnly: an intercept-only design skipped the F
|
||
|
|
// block and left FPValue at its zero value, reporting the null model
|
||
|
|
// as maximally significant.
|
||
|
|
func TestRegressionInterceptOnly(t *testing.T) {
|
||
|
|
y, err := core.FromFloats([]float64{2, 4, 6, 8}, 4)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
one, err := core.FromFloats([]float64{1, 1, 1, 1}, 4, 1)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
for name, run := range map[string]func() (*LinearRegressionResult, error){
|
||
|
|
"LinearRegression": func() (*LinearRegressionResult, error) { return LinearRegression(one, y) },
|
||
|
|
"WeightedLinearRegression": func() (*LinearRegressionResult, error) {
|
||
|
|
w, _ := core.FromFloats([]float64{1, 1, 1, 1}, 4)
|
||
|
|
return WeightedLinearRegression(one, y, w)
|
||
|
|
},
|
||
|
|
} {
|
||
|
|
res, err := run()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("%s: %v", name, err)
|
||
|
|
}
|
||
|
|
if res.FPValue != 1 {
|
||
|
|
t.Fatalf("%s: intercept-only FPValue = %g, want 1", name, res.FPValue)
|
||
|
|
}
|
||
|
|
if res.FStatistic != 0 {
|
||
|
|
t.Fatalf("%s: intercept-only FStatistic = %g, want 0", name, res.FStatistic)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestHistogramInt64Exact: the samples 2^53, 2^53+1, 2^53+2 all
|
||
|
|
// widened to the same float64 and landed in the wrong bins; the exact
|
||
|
|
// integer path bins them by their true values.
|
||
|
|
func TestHistogramInt64Exact(t *testing.T) {
|
||
|
|
const base = 1 << 53
|
||
|
|
x, err := core.FromInts([]int64{base + 1, base, base + 2}, 3)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
counts, _, err := Histogram(x, 2)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if c0, _ := core.IntAt(counts, 0); c0 != 1 {
|
||
|
|
t.Fatalf("bin 0 count = %d, want 1 (only the minimum)", c0)
|
||
|
|
}
|
||
|
|
if c1, _ := core.IntAt(counts, 1); c1 != 2 {
|
||
|
|
t.Fatalf("bin 1 count = %d, want 2", c1)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestMedianQuantileInt64Exact: the median of {2^53, 2^53+1} came
|
||
|
|
// back as 2^53 although 2^53+0.5 is representable; the same held for
|
||
|
|
// the quantile at 0.5, whose interpolation collapsed on the rounded
|
||
|
|
// pair.
|
||
|
|
func TestMedianQuantileInt64Exact(t *testing.T) {
|
||
|
|
const base = 1 << 53
|
||
|
|
x, err := core.FromInts([]int64{base, base + 1}, 2)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
m, err := Median(x)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if m != base+0.5 {
|
||
|
|
t.Fatalf("Median = %g, want %g", m, base+0.5)
|
||
|
|
}
|
||
|
|
q, err := Quantile(x, []float64{0.5})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if q.FloatAt(0) != base+0.5 {
|
||
|
|
t.Fatalf("Quantile(0.5) = %g, want %g", q.FloatAt(0), base+0.5)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestGammaUpperOwnErrorName: GammaUpper reported the GammaLower
|
||
|
|
// prefix on the shape refusal.
|
||
|
|
func TestGammaUpperOwnErrorName(t *testing.T) {
|
||
|
|
if _, err := GammaUpper(0, 1); err == nil || !strings.Contains(err.Error(), "GammaUpper:") {
|
||
|
|
t.Fatalf("GammaUpper(0, 1): err = %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestWelchTTestRejectsNaN: a NaN sample only surfaced when the tail
|
||
|
|
// function rejected the NaN statistic, under its own name.
|
||
|
|
func TestWelchTTestRejectsNaN(t *testing.T) {
|
||
|
|
a, err := core.FromFloats([]float64{math.NaN(), 2, 3}, 3)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
b, err := core.FromFloats([]float64{1, 2, 3}, 3)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, _, err := WelchTTest(a, b); err == nil || !strings.Contains(err.Error(), "WelchTTest") {
|
||
|
|
t.Fatalf("WelchTTest on a NaN sample: err = %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestMannWhitneyUTieOverflow: a tie block of 2^21+1 observations
|
||
|
|
// overflowed the integer tie term, wrapped it negative and bypassed
|
||
|
|
// the every-observation-tied refusal.
|
||
|
|
func TestMannWhitneyUTieOverflow(t *testing.T) {
|
||
|
|
const n = 1<<21 + 1
|
||
|
|
vals := make([]float64, n)
|
||
|
|
for i := range vals {
|
||
|
|
vals[i] = 7
|
||
|
|
}
|
||
|
|
a, err := core.FromFloats(vals, n)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, err := MannWhitneyU(a, a); err == nil || !strings.Contains(err.Error(), "tied") {
|
||
|
|
t.Fatalf("MannWhitneyU on one giant tie block: err = %v", err)
|
||
|
|
}
|
||
|
|
}
|