155 lines
5.4 KiB
Go
155 lines
5.4 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"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// TestQuantileDeepTails pins the deep-left-tail quantiles that
|
|||
|
|
// the old 200-pass bisection silently mis-answered and the 1e300
|
|||
|
|
// bracket floors refused: the answers must be within a rounding of the
|
|||
|
|
// exact values, never an unconverged midpoint with a nil error.
|
|||
|
|
func TestQuantileDeepTails(t *testing.T) {
|
|||
|
|
v, err := ExponentialQuantile(1e-100, 1)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("ExponentialQuantile: %v", err)
|
|||
|
|
}
|
|||
|
|
if relErr(v, 1e-100) > 1e-6 {
|
|||
|
|
t.Fatalf("ExponentialQuantile(1e-100, 1) = %g, want 1e-100", v)
|
|||
|
|
}
|
|||
|
|
v, err = ChiSquareQuantile(1e-30, 1)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("ChiSquareQuantile: %v", err)
|
|||
|
|
}
|
|||
|
|
// The exact χ²(1) deep lower tail: x = z² with Φ(z) = 0.5 + q/2,
|
|||
|
|
// so z ≈ (q/2)/φ(0) and x ≈ (π/2)·q².
|
|||
|
|
want := math.Pi / 2 * 1e-60
|
|||
|
|
if relErr(v, want) > 1e-4 {
|
|||
|
|
t.Fatalf("ChiSquareQuantile(1e-30, 1) = %g, want %g", v, want)
|
|||
|
|
}
|
|||
|
|
v, err = GammaQuantile(1e-100, 0.5, 0.5)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("GammaQuantile: %v", err)
|
|||
|
|
}
|
|||
|
|
if v < 0.5*1e-200 || v > 2*1e-200 {
|
|||
|
|
t.Fatalf("GammaQuantile(1e-100, 0.5, 0.5) = %g, want ≈ 0.785e-200 (χ²(1)/2 at q²)", v)
|
|||
|
|
}
|
|||
|
|
// The denormal floor: the smallest representable q still answers
|
|||
|
|
// the smallest representable scale, or refuses loudly; never a
|
|||
|
|
// silent wrong number.
|
|||
|
|
if _, err = ExponentialQuantile(5e-324, 1); err != nil {
|
|||
|
|
t.Fatalf("ExponentialQuantile(5e-324): %v", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func relErr(got, want float64) float64 {
|
|||
|
|
d := math.Abs(got - want)
|
|||
|
|
if want == 0 {
|
|||
|
|
return d
|
|||
|
|
}
|
|||
|
|
return d / math.Abs(want)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestNormalQuantileExtremeTail pins that a q below 2⁻⁵³, where
|
|||
|
|
// 1−q rounds to exactly 1, still answers through the accurate upper
|
|||
|
|
// tail instead of the bogus "q = 1" refusal.
|
|||
|
|
func TestNormalQuantileExtremeTail(t *testing.T) {
|
|||
|
|
v, err := NormalQuantile(1e-17)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("NormalQuantile(1e-17): %v", err)
|
|||
|
|
}
|
|||
|
|
// Φ(−8.2907496456... ) = 1e-17 to the digits that matter here.
|
|||
|
|
if relErr(0.5*math.Erfc(-v/math.Sqrt2), 1e-17) > 1e-6 {
|
|||
|
|
t.Fatalf("NormalQuantile(1e-17) = %g, tail = %g", v, 0.5*math.Erfc(-v/math.Sqrt2))
|
|||
|
|
}
|
|||
|
|
v, err = StudentTQuantile(1e-17, 5)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("StudentTQuantile(1e-17, 5): %v", err)
|
|||
|
|
}
|
|||
|
|
tail, err := studentTUpperTail(-v, 5)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("studentTUpperTail: %v", err)
|
|||
|
|
}
|
|||
|
|
// The quantile mirrors the one-sided tail: P(T > −t) = q.
|
|||
|
|
if relErr(tail, 1e-17) > 1e-6 {
|
|||
|
|
t.Fatalf("StudentTQuantile(1e-17, 5) = %g, one-sided tail = %g, want %g", v, tail, 1e-17)
|
|||
|
|
}
|
|||
|
|
// The heavy tails stay representable far past where t² overflows:
|
|||
|
|
// Cauchy answers 1/(πq) exactly, df = 2 answers 1/sqrt(2q), where
|
|||
|
|
// the incomplete-beta argument used to saturate to a phantom zero
|
|||
|
|
// and clamp the quantile at the overflow wall.
|
|||
|
|
vc, err := StudentTQuantile(1e-200, 1)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("StudentTQuantile(1e-200, 1): %v", err)
|
|||
|
|
}
|
|||
|
|
if want := -1 / (math.Pi * 1e-200); relErr(vc, want) > 1e-6 {
|
|||
|
|
t.Fatalf("StudentTQuantile(1e-200, 1) = %g, want %g", vc, want)
|
|||
|
|
}
|
|||
|
|
v2, err := StudentTQuantile(1e-160, 2)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("StudentTQuantile(1e-160, 2): %v", err)
|
|||
|
|
}
|
|||
|
|
if want := -1 / math.Sqrt(2*1e-160); relErr(v2, want) > 1e-6 {
|
|||
|
|
t.Fatalf("StudentTQuantile(1e-160, 2) = %g, want %g", v2, want)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestRegressionConstantResponse pins R² = 1 for a constant
|
|||
|
|
// response reproduced exactly, where the 1 − 0/0 form reported NaN
|
|||
|
|
// with a nil error.
|
|||
|
|
func TestRegressionConstantResponse(t *testing.T) {
|
|||
|
|
x := mustFloats(t, []float64{1, 0, 1, 1, 1, 2, 1, 3}, 4, 2)
|
|||
|
|
y := mustFloats(t, []float64{5, 5, 5, 5}, 4)
|
|||
|
|
res, err := LinearRegression(x, y)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("LinearRegression: %v", err)
|
|||
|
|
}
|
|||
|
|
if res.RSquared != 1 || res.AdjustedRSquared != 1 {
|
|||
|
|
t.Fatalf("constant response: R² = %v, adj = %v, want 1 and 1", res.RSquared, res.AdjustedRSquared)
|
|||
|
|
}
|
|||
|
|
w := mustFloats(t, []float64{1, 2, 1, 1}, 4)
|
|||
|
|
wres, err := WeightedLinearRegression(x, y, w)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("WeightedLinearRegression: %v", err)
|
|||
|
|
}
|
|||
|
|
if wres.RSquared != 1 || wres.AdjustedRSquared != 1 {
|
|||
|
|
t.Fatalf("weighted constant response: R² = %v, adj = %v, want 1 and 1", wres.RSquared, wres.AdjustedRSquared)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestHistogramFullRange pins the refusal of a sample holding
|
|||
|
|
// both float extremes, whose edges would be ±Inf and whose counts
|
|||
|
|
// silently collapsed into bin 0.
|
|||
|
|
func TestHistogramFullRange(t *testing.T) {
|
|||
|
|
a := mustFloats(t, []float64{-math.MaxFloat64, 0, math.MaxFloat64})
|
|||
|
|
if _, _, err := Histogram(a, 2); err == nil {
|
|||
|
|
t.Fatal("Histogram: expected a range error")
|
|||
|
|
}
|
|||
|
|
b := mustFloats(t, []float64{-math.MaxFloat64, 1, math.MaxFloat64, 4}, 2, 2)
|
|||
|
|
bx, err := core.Slice(b, 1, 0, 1)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("Slice: %v", err)
|
|||
|
|
}
|
|||
|
|
if _, _, _, err := Histogram2D(bx, b, 2, 2); err == nil {
|
|||
|
|
t.Fatal("Histogram2D: expected a range error")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestChiSquareGOFRejectsInfiniteExpected pins that an
|
|||
|
|
// infinite expectation is refused at the entry point, under its own
|
|||
|
|
// name, rather than surfacing as a NaN inside the tail function.
|
|||
|
|
func TestChiSquareGOFRejectsInfiniteExpected(t *testing.T) {
|
|||
|
|
obs := mustFloats(t, []float64{10, 12, 9})
|
|||
|
|
exp := mustFloats(t, []float64{math.Inf(1), 10, 10})
|
|||
|
|
_, _, _, err := ChiSquareGoodnessOfFit(obs, exp)
|
|||
|
|
if err == nil || !strings.Contains(err.Error(), "expected frequencies") {
|
|||
|
|
t.Fatalf("ChiSquareGoodnessOfFit: err = %v, want the expected-frequencies refusal", err)
|
|||
|
|
}
|
|||
|
|
}
|