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

279 lines
8.9 KiB
Go
Raw 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"
)
// TestGammaIncompleteClosed checks the incomplete gamma against
// closed forms: P(1, x) = 1 − e^{−x}, P(0.5, x) = erf(√x),
// Q(2, 1) = 3/e and the complement identity.
func TestGammaIncompleteClosed(t *testing.T) {
p, err := GammaLower(1, 2.5)
if err != nil {
t.Fatalf("GammaLower: %v", err)
}
if math.Abs(p-(1-math.Exp(-2.5))) > 1e-14 {
t.Fatalf("P(1, 2.5) = %.16g, want %.16g", p, 1-math.Exp(-2.5))
}
p, err = GammaLower(0.5, 1)
if err != nil {
t.Fatalf("GammaLower: %v", err)
}
if math.Abs(p-math.Erf(1)) > 1e-14 {
t.Fatalf("P(0.5, 1) = %.16g, want erf(1) = %.16g", p, math.Erf(1))
}
q, err := GammaUpper(2, 1)
if err != nil {
t.Fatalf("GammaUpper: %v", err)
}
if math.Abs(q-2/math.E) > 1e-14 {
t.Fatalf("Q(2, 1) = %.16g, want 2/e = %.16g", q, 2/math.E)
}
p, err = GammaLower(3, 0.7)
if err != nil {
t.Fatalf("GammaLower: %v", err)
}
q, err = GammaUpper(3, 0.7)
if err != nil {
t.Fatalf("GammaUpper: %v", err)
}
if math.Abs(p+q-1) > 1e-14 {
t.Fatalf("P + Q = %.17g, want 1", p+q)
}
// The series side of a large shape and the fraction side of a
// small one must agree with the complement to rounding level.
p, _ = GammaLower(8.5, 9.3)
q, _ = GammaUpper(8.5, 9.3)
if math.Abs(p+q-1) > 1e-13 {
t.Fatalf("P(8.5, 9.3) + Q(8.5, 9.3) = %.17g, want 1", p+q)
}
if _, err := GammaLower(0, 1); err == nil {
t.Fatal("a = 0: want an error")
}
if _, err := GammaLower(1, -1); err == nil {
t.Fatal("negative x: want an error")
}
}
// TestBetaIncompleteClosed checks the incomplete beta against closed
// forms and the symmetry I_x(a,b) = 1 − I_{1−x}(b,a).
func TestBetaIncompleteClosed(t *testing.T) {
cases := []struct {
x, a, b, want float64
}{
{0.3, 1, 1, 0.3}, // uniform
{0.2, 1, 3, 0.488}, // 1 − (1−x)^b
{0.09, 2, 1, 0.0081}, // x^a
{0.5, 2, 2, 0.5}, // symmetric
{0.5, 0.5, 0.5, 0.5}, // arcsine, symmetric
{0.25, 0.5, 1, 0.5}, // I_x(1/2,1) = √x
}
for _, c := range cases {
got, err := BetaIncomplete(c.x, c.a, c.b)
if err != nil {
t.Fatalf("BetaIncomplete(%g, %g, %g): %v", c.x, c.a, c.b, err)
}
if math.Abs(got-c.want) > 1e-13 {
t.Fatalf("I_%g(%g, %g) = %.16g, want %.16g", c.x, c.a, c.b, got, c.want)
}
}
got, err := BetaIncomplete(0.7, 2, 3)
if err != nil {
t.Fatalf("BetaIncomplete: %v", err)
}
mirror, err := BetaIncomplete(0.3, 3, 2)
if err != nil {
t.Fatalf("BetaIncomplete mirror: %v", err)
}
if math.Abs(got+mirror-1) > 1e-13 {
t.Fatalf("symmetry broken: %.17g + %.17g != 1", got, mirror)
}
if _, err := BetaIncomplete(1.5, 1, 1); err == nil {
t.Fatal("x outside [0, 1]: want an error")
}
}
// TestContinuousCDFs pins each continuous CDF on exact or tabulated
// values.
func TestContinuousCDFs(t *testing.T) {
if v := NormalCDF(1); math.Abs(v-0.8413447460685429) > 1e-15 {
t.Fatalf("Φ(1) = %.16g", v)
}
if v := NormalCDF(1.959963984540054); math.Abs(v-0.975) > 1e-14 {
t.Fatalf("Φ(1.96…) = %.16g, want 0.975", v)
}
v, err := ExponentialCDF(1, 1)
if err != nil || math.Abs(v-(1-1/math.E)) > 1e-15 {
t.Fatalf("ExponentialCDF(1, 1) = %v, %v", v, err)
}
// Gamma(2, 1) CDF: 1 − e^{−x}(1 + x).
v, err = GammaCDF(1, 2, 1)
if err != nil || math.Abs(v-(1-2/math.E)) > 1e-14 {
t.Fatalf("GammaCDF(1, 2, 1) = %v, %v", v, err)
}
// χ² with df 2 is the exponential with mean 2: 1 − e^{−x/2}.
v, err = ChiSquareCDF(1, 2)
if err != nil || math.Abs(v-(1-math.Exp(-0.5))) > 1e-14 {
t.Fatalf("ChiSquareCDF(1, 2) = %v, %v", v, err)
}
// χ² 95 % critical value with df 1: 3.841458820694124.
v, err = ChiSquareCDF(3.841458820694124, 1)
if err != nil || math.Abs(v-0.95) > 1e-13 {
t.Fatalf("ChiSquareCDF at the tabulated 95 %% point = %v, %v", v, err)
}
// Student t with df 1 is the Cauchy: 0.5 + atan(t)/π.
v, err = StudentTCDF(1, 1)
if err != nil || math.Abs(v-(0.5+math.Atan(1)/math.Pi)) > 1e-14 {
t.Fatalf("StudentTCDF(1, 1) = %v, %v", v, err)
}
// Student t with df 2: 0.5 + t/(2√(2 + t²)).
v, err = StudentTCDF(1, 2)
if err != nil || math.Abs(v-(0.5+1/(2*math.Sqrt(3)))) > 1e-14 {
t.Fatalf("StudentTCDF(1, 2) = %v, %v", v, err)
}
}
// TestDiscreteCDFs pins the Poisson and binomial CDFs on exact sums.
func TestDiscreteCDFs(t *testing.T) {
v, err := PoissonCDF(1, 1)
if err != nil || math.Abs(v-2/math.E) > 1e-14 {
t.Fatalf("PoissonCDF(1, 1) = %v, %v, want 2/e", v, err)
}
// P(N ≤ 4) for λ = 4: e^{−4}·Σ_{k≤4} 4^k/k!.
sum := 0.0
for k, term := 0, 1.0; k <= 4; k++ {
if k > 0 {
term *= 4 / float64(k)
}
sum += term
}
v, err = PoissonCDF(4, 4)
if err != nil || math.Abs(v-sum*math.Exp(-4)) > 1e-14 {
t.Fatalf("PoissonCDF(4, 4) = %v, %v, want %.16g", v, err, sum*math.Exp(-4))
}
// Binomial(10, 0.5) at 5: 638/1024 by symmetry of the row.
v, err = BinomialCDF(5, 10, 0.5)
if err != nil || math.Abs(v-638.0/1024) > 1e-14 {
t.Fatalf("BinomialCDF(5, 10, 0.5) = %v, %v, want 0.623046875", v, err)
}
v, err = BinomialCDF(-1, 10, 0.5)
if err != nil || v != 0 {
t.Fatalf("BinomialCDF(-1, …) = %v, %v", v, err)
}
v, err = BinomialCDF(10, 10, 0.5)
if err != nil || v != 1 {
t.Fatalf("BinomialCDF(10, 10, 0.5) = %v, %v", v, err)
}
}
// TestContinuousQuantiles inverts every continuous CDF and pins the
// tabulated critical values.
func TestContinuousQuantiles(t *testing.T) {
v, err := NormalQuantile(0.975)
if err != nil || math.Abs(v-1.959963984540054) > 1e-12 {
t.Fatalf("NormalQuantile(0.975) = %v, %v", v, err)
}
v, err = StudentTQuantile(0.975, 10)
if err != nil || math.Abs(v-2.228138851986273) > 1e-9 {
t.Fatalf("StudentTQuantile(0.975, 10) = %v, %v, want 2.2281…", v, err)
}
neg, err := StudentTQuantile(0.025, 10)
if err != nil || math.Abs(neg+2.228138851986273) > 1e-9 {
t.Fatalf("StudentTQuantile(0.025, 10) = %v, %v", neg, err)
}
v, err = ChiSquareQuantile(0.95, 1)
if err != nil || math.Abs(v-3.841458820694124) > 1e-9 {
t.Fatalf("ChiSquareQuantile(0.95, 1) = %v, %v", v, err)
}
v, err = ExponentialQuantile(0.6321205588285577, 1)
if err != nil || math.Abs(v-1) > 1e-12 {
t.Fatalf("ExponentialQuantile(1−1/e, 1) = %v, %v", v, err)
}
v, err = GammaQuantile(0.5, 2, 1)
if err != nil || math.Abs(v-1.678346990016661) > 1e-9 {
t.Fatalf("GammaQuantile(0.5, 2, 1) = %v, %v, want 1.67834…", v, err)
}
// Round trip: the CDF at every quantile must return q.
for _, q := range []float64{0.01, 0.1, 0.5, 0.9, 0.999} {
v, err := GammaQuantile(q, 3.5, 2)
if err != nil {
t.Fatalf("GammaQuantile(%g): %v", q, err)
}
back, err := GammaCDF(v, 3.5, 2)
if err != nil || math.Abs(back-q) > 1e-11 {
t.Fatalf("round trip q = %g: CDF(quantile) = %v, %v", q, back, err)
}
}
if _, err := NormalQuantile(1.5); err == nil {
t.Fatal("q outside [0, 1]: want an error")
}
if _, err := NormalQuantile(0); err == nil {
t.Fatal("q = 0: want an error")
}
}
// TestDiscreteQuantiles checks the smallest-k rule on exact cases.
func TestDiscreteQuantiles(t *testing.T) {
v, err := PoissonQuantile(0.5, 1)
if err != nil || v != 1 {
t.Fatalf("PoissonQuantile(0.5, 1) = %v, %v, want 1", v, err)
}
// P(N ≤ 2) = 5/(2e) ≈ 0.9197, P(N ≤ 1) = 2/e ≈ 0.7358 for λ = 1:
// the 0.8 quantile is the smallest k reaching it, k = 2.
v, err = PoissonQuantile(0.8, 1)
if err != nil || v != 2 {
t.Fatalf("PoissonQuantile(0.8, 1) = %v, %v, want 2", v, err)
}
// Binomial(10, 0.5) median: smallest k with CDF ≥ 0.5 is 5.
v, err = BinomialQuantile(0.5, 0.5, 10)
if err != nil || v != 5 {
t.Fatalf("BinomialQuantile(0.5, 0.5, 10) = %v, %v, want 5", v, err)
}
if _, err := PoissonQuantile(0.5, 0); err == nil {
t.Fatal("lambda = 0: want an error")
}
}
// TestGammaLowerLargeShape pins the large-shape region: both the power
// series (x < a+1) and the continued fraction (x ≥ a+1) must deliver
// accurate values near x ≈ a where the shape makes √a-sized iteration
// counts necessary, instead of silently returning truncated sums.
// The reference is the Wilson-Hilferty normal approximation, good to
// a few digits at these shapes.
func TestGammaLowerLargeShape(t *testing.T) {
wh := func(a, x float64) float64 {
z := 3 * math.Sqrt(a) * (math.Cbrt(x/a) - (1 - 1/(9*a)))
return 0.5 * math.Erfc(-z/math.Sqrt2)
}
cases := []struct {
a, x, tol float64
}{
{50000, 50000, 1e-4}, // series branch, √a ≈ 224
{50000, 49500, 5e-4}, // lower tail, series
{50000, 50500, 5e-4}, // upper tail, continued fraction
{200000, 200000, 1e-4},
{1000, 1000, 1e-5},
}
for _, tc := range cases {
p, err := GammaLower(tc.a, tc.x)
if err != nil {
t.Fatalf("GammaLower(%g, %g): %v", tc.a, tc.x, err)
}
want := wh(tc.a, tc.x)
if math.Abs(p-want) > tc.tol {
t.Fatalf("GammaLower(%g, %g) = %.10g, want ≈ %.10g (tol %g)", tc.a, tc.x, p, want, tc.tol)
}
q, err := GammaUpper(tc.a, tc.x)
if err != nil {
t.Fatalf("GammaUpper(%g, %g): %v", tc.a, tc.x, err)
}
if p+q != 1 {
t.Fatalf("GammaLower + GammaUpper = %.17g, want exactly 1", p+q)
}
}
}