Files
tensor/stats/noncentral_test.go
T

498 lines
16 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package stats
import (
"math"
"sourcedock.dev/petrbalvin/tensor/internal/core"
"strings"
"testing"
)
// TestNoncentralChiSquareClosed pins the noncentral χ² on the exact
// closed form its df = 1 corner carries: χ²(1, λ) is the square of a
// N(√λ, 1) draw, so the CDF is Φ(√x−√λ) − Φ(−√x−√λ), and on the λ = 0
// reduction to the central law.
func TestNoncentralChiSquareClosed(t *testing.T) {
for _, lambda := range []float64{0.5, 1, 4, 9, 25} {
for _, x := range []float64{0.5, 1, 2, 4, 9, 16} {
got, err := NoncentralChiSquareCDF(x, 1, lambda)
if err != nil {
t.Fatalf("NoncentralChiSquareCDF(%g, 1, %g): %v", x, lambda, err)
}
root := math.Sqrt(lambda)
want := NormalCDF(math.Sqrt(x)-root) - NormalCDF(-math.Sqrt(x)-root)
if math.Abs(got-want) > 1e-13 {
t.Fatalf("NoncentralChiSquareCDF(%g, 1, %g) = %.16g, want %.16g", x, lambda, got, want)
}
}
}
for _, df := range []int{1, 2, 5, 10} {
for _, x := range []float64{0.5, 2, 7} {
got, err := NoncentralChiSquareCDF(x, df, 0)
if err != nil {
t.Fatalf("NoncentralChiSquareCDF(%g, %d, 0): %v", x, df, err)
}
want, err := ChiSquareCDF(x, df)
if err != nil || math.Abs(got-want) > 1e-14 {
t.Fatalf("λ = 0 reduction at df %d: %v vs %v (%v)", df, got, want, err)
}
}
}
if v, _ := NoncentralChiSquareCDF(-1, 3, 2); v != 0 {
t.Fatalf("CDF below the support = %v, want 0", v)
}
if v, _ := NoncentralChiSquareDensity(-1, 3, 2); v != 0 {
t.Fatalf("density below the support = %v, want 0", v)
}
}
// TestNoncentralChiSquareDensityIntegral integrates the density against
// the CDF. The walk runs after the substitution x = s², which leaves
// 2s·f(s²) smooth at the origin for every df (the density itself
// behaves like x^{df/2−1} there, too flat a start for Simpson's error
// estimate on the lower degrees). Simpson must then reproduce the CDF
// to better than 1e-10 relative, the double route the closed forms
// cannot cover.
func TestNoncentralChiSquareDensityIntegral(t *testing.T) {
type grid struct {
df int
lambda float64
x float64
}
for _, g := range []grid{
{2, 1, 6}, {3, 1, 6}, {3, 4, 10}, {5, 3, 12}, {10, 25, 60},
} {
n := 200000
s := math.Sqrt(g.x)
sh := s / float64(n)
f := func(sv float64) float64 {
if sv == 0 {
return 0
}
xv := sv * sv
d, err := NoncentralChiSquareDensity(xv, g.df, g.lambda)
if err != nil {
t.Fatalf("NoncentralChiSquareDensity: %v", err)
}
return 2 * sv * d
}
sum := f(0) + f(s)
for i := 1; i < n; i++ {
w := 4.0
if i%2 == 0 {
w = 2
}
sum += w * f(float64(i)*sh)
}
integral := sum * sh / 3
cdf, err := NoncentralChiSquareCDF(g.x, g.df, g.lambda)
if err != nil {
t.Fatalf("NoncentralChiSquareCDF: %v", err)
}
if rel := math.Abs(integral-cdf) / cdf; rel > 1e-10 {
t.Fatalf("df = %d, λ = %g, x = %g: density integral %.15g vs CDF %.15g (rel %g)",
g.df, g.lambda, g.x, integral, cdf, rel)
}
}
}
// noncentralTOracle evaluates E[Φ(t√(V/ν) − δ)] for V ~ χ²(ν) by
// Simpson after the substitution V = u², which leaves the integrand
// smooth for every ν; the u = 0 limit is finite only for ν = 1.
func noncentralTOracle(t float64, df int, delta float64) float64 {
uhi := 40.0
n := 200000
h := uhi / float64(n)
logGammaB := func(a float64) float64 { l, _ := math.Lgamma(a); return l }
f := func(u float64) float64 {
if u == 0 {
if df == 1 {
return math.Sqrt(2/math.Pi) * NormalCDF(-delta)
}
return 0
}
v := u * u
fv := 2 * u * math.Exp((float64(df)/2-1)*math.Log(v)-v/2-(float64(df)/2)*math.Ln2-logGammaB(float64(df)/2))
return NormalCDF(t*math.Sqrt(v/float64(df))-delta) * fv
}
sum := f(0) + f(uhi)
for i := 1; i < n; i++ {
w := 4.0
if i%2 == 0 {
w = 2
}
sum += w * f(float64(i)*h)
}
return sum * h / 3
}
// TestNoncentralTAgainstQuadrature holds the Lenth series against a
// direct quadrature of E[Φ(t√(V/ν) − δ)], an independent route that
// shares no code with the series, at a grid spanning both signs of t
// and δ and degrees of freedom from 1 to 10. The large-δ cases are the
// underflow round: a noncentrality whose Poisson weight seed e^{−δ²/2}
// is below the double floor used to silence the whole series, and each
// of them once answered a silent 0. Tolerance 1e-9, an order above the
// quadrature's own accuracy.
func TestNoncentralTAgainstQuadrature(t *testing.T) {
for _, df := range []int{1, 2, 5, 10} {
for _, delta := range []float64{-3, -0.5, 0.5, 2} {
for _, tv := range []float64{-2, -0.5, 0.4, 1, 3} {
got, err := NoncentralTCDF(tv, df, delta)
if err != nil {
t.Fatalf("NoncentralTCDF(%g, %d, %g): %v", tv, df, delta, err)
}
want := noncentralTOracle(tv, df, delta)
if math.Abs(got-want) > 1e-9 {
t.Fatalf("NoncentralTCDF(%g, %d, %g) = %.15g, want quadrature %.15g",
tv, df, delta, got, want)
}
}
}
}
for _, c := range []struct {
tv, delta float64
df int
}{
{45, 45, 5},
{50, 40, 3},
{-45, -45, 5},
{100, 45, 5},
} {
got, err := NoncentralTCDF(c.tv, c.df, c.delta)
if err != nil {
t.Fatalf("NoncentralTCDF(%g, %d, %g): %v", c.tv, c.df, c.delta, err)
}
want := noncentralTOracle(c.tv, c.df, c.delta)
if math.Abs(got-want) > 1e-9 {
t.Fatalf("NoncentralTCDF(%g, %d, %g) = %.15g, want quadrature %.15g",
c.tv, c.df, c.delta, got, want)
}
}
}
// TestNoncentralUnderflowSurvival pins the deep-noncentrality window
// where the mixture weights' raw seed underflows: the χ² CDF at its
// own mean answers a half, the far tail answers a genuinely computed
// negligible value rather than a silent zero, the F CDF saturates at
// 1 past the overflow of ν₁x, and the χ² density at the origin keeps
// the finite df = 2 limit. Past the term budget the refusal is
// explicit.
func TestNoncentralUnderflowSurvival(t *testing.T) {
atMean, err := NoncentralChiSquareCDF(2005, 5, 2000)
if err != nil {
t.Fatal(err)
}
if atMean < 0.48 || atMean > 0.52 {
t.Fatalf("NoncentralChiSquareCDF at the mean 2005 = %g, want near a half", atMean)
}
tail, err := NoncentralChiSquareCDF(1005, 5, 2000)
if err != nil {
t.Fatal(err)
}
if !(tail >= 0 && tail < 1e-30) {
t.Fatalf("NoncentralChiSquareCDF(1005, 5, 2000) = %g, want a negligible non-negative tail", tail)
}
for _, x := range []float64{math.MaxFloat64, math.Inf(1)} {
f, err := NoncentralFCDF(x, 4, 10, 3)
if err != nil {
t.Fatal(err)
}
if math.Abs(f-1) > 1e-15 {
t.Fatalf("NoncentralFCDF(%g, 4, 10, 3) = %g, want 1 to rounding", x, f)
}
}
d, err := NoncentralChiSquareDensity(0, 2, 3)
if err != nil {
t.Fatal(err)
}
if want := 0.5 * math.Exp(-1.5); d != want {
t.Fatalf("NoncentralChiSquareDensity(0, 2, 3) = %g, want the limit %g", d, want)
}
if _, err := NoncentralChiSquareCDF(10, 5, 4e5); err == nil || !strings.Contains(err.Error(), "budget") {
t.Fatalf("lambda 4e5: error = %v, want the budget refusal", err)
}
if _, err := NoncentralTCDF(10, 5, 500); err == nil || !strings.Contains(err.Error(), "budget") {
t.Fatalf("delta 500: error = %v, want the budget refusal", err)
}
}
// TestNoncentralTIdentityReductions pins the exact corners: δ = 0 is
// the central Student t, t = 0 is Φ(−δ), and the two reflection
// identities of the law hold to rounding.
func TestNoncentralTIdentityReductions(t *testing.T) {
for _, df := range []int{1, 3, 8} {
for _, tv := range []float64{-4, -1, 0.3, 2} {
got, err := NoncentralTCDF(tv, df, 0)
if err != nil {
t.Fatalf("NoncentralTCDF(%g, %d, 0): %v", tv, df, err)
}
want, err := StudentTCDF(tv, df)
if err != nil || got != want {
t.Fatalf("δ = 0 reduction at (%g, %d): %v vs %v (%v)", tv, df, got, want, err)
}
}
}
for _, delta := range []float64{-3, -0.5, 1, 4} {
got, _ := NoncentralTCDF(0, 5, delta)
if want := NormalCDF(-delta); math.Abs(got-want) > 1e-15 {
t.Fatalf("NoncentralTCDF(0, 5, %g) = %.16g, want Φ(−δ) = %.16g", delta, got, want)
}
}
for _, delta := range []float64{-2, 1.5} {
for _, tv := range []float64{-1, 0.7, 2} {
pos, _ := NoncentralTCDF(tv, 4, delta)
reflected, _ := NoncentralTCDF(-tv, 4, -delta)
if math.Abs(pos+reflected-1) > 1e-14 {
t.Fatalf("reflection broken at t = %g, δ = %g: %.17g", tv, delta, pos+reflected)
}
mirrored, _ := NoncentralTCDF(-tv, 4, delta)
flipped, _ := NoncentralTCDF(tv, 4, -delta)
if math.Abs(flipped-(1-mirrored)) > 1e-14 {
t.Fatalf("sign symmetry broken at t = %g, δ = %g: %.17g vs %.17g",
tv, delta, flipped, 1-mirrored)
}
}
}
}
// TestNoncentralFIdentityReductions pins the noncentral F on its λ = 0
// central reduction and on the df₁ = 1 identity with the noncentral t:
// F(1, ν, λ) is the squared t(ν, √λ), so P(F ≤ y) is the t CDF across
// ±√y. The CDF must also fall as the noncentrality grows.
func TestNoncentralFIdentityReductions(t *testing.T) {
for _, df2 := range []int{1, 4, 12} {
for _, x := range []float64{0.3, 1, 2.5} {
got, err := NoncentralFCDF(x, 1, df2, 0)
if err != nil {
t.Fatalf("NoncentralFCDF(%g, 1, %d, 0): %v", x, df2, err)
}
want, err := BetaIncomplete(x/(x+float64(df2)), 0.5, float64(df2)/2)
if err != nil || math.Abs(got-want) > 1e-14 {
t.Fatalf("central reduction at x = %g, df₂ = %d: %v vs %v (%v)", x, df2, got, want, err)
}
}
}
for _, lambda := range []float64{1, 4} {
for _, df2 := range []int{2, 6} {
for _, y := range []float64{0.5, 2, 6} {
got, err := NoncentralFCDF(y, 1, df2, lambda)
if err != nil {
t.Fatalf("NoncentralFCDF(%g, 1, %d, %g): %v", y, df2, lambda, err)
}
root := math.Sqrt(lambda)
hi, _ := NoncentralTCDF(math.Sqrt(y), df2, root)
lo, _ := NoncentralTCDF(-math.Sqrt(y), df2, root)
if math.Abs(got-(hi-lo)) > 1e-13 {
t.Fatalf("t² identity at y = %g, ν = %d, λ = %g: %.15g vs %.15g",
y, df2, lambda, got, hi-lo)
}
}
}
}
central, _ := NoncentralFCDF(2, 3, 8, 0)
shifted, _ := NoncentralFCDF(2, 3, 8, 5)
if shifted >= central {
t.Fatalf("a larger λ lowered the CDF from %g to %g", central, shifted)
}
}
// TestNoncentralQuantileRoundTrips inverts each noncentral CDF and
// checks the CDF at the quantile returns q.
func TestNoncentralQuantileRoundTrips(t *testing.T) {
for _, q := range []float64{0.01, 0.25, 0.5, 0.9, 0.99} {
x, err := NoncentralChiSquareQuantile(q, 5, 3)
if err != nil {
t.Fatalf("NoncentralChiSquareQuantile(%g): %v", q, err)
}
back, _ := NoncentralChiSquareCDF(x, 5, 3)
if math.Abs(back-q) > 1e-10 {
t.Fatalf("χ² round trip q = %g: CDF(quantile) = %v", q, back)
}
tq, err := NoncentralTQuantile(q, 5, 2)
if err != nil {
t.Fatalf("NoncentralTQuantile(%g): %v", q, err)
}
tback, _ := NoncentralTCDF(tq, 5, 2)
if math.Abs(tback-q) > 1e-10 {
t.Fatalf("t round trip q = %g: CDF(quantile) = %v", q, tback)
}
fq, err := NoncentralFQuantile(q, 4, 10, 2)
if err != nil {
t.Fatalf("NoncentralFQuantile(%g): %v", q, err)
}
fback, _ := NoncentralFCDF(fq, 4, 10, 2)
if math.Abs(fback-q) > 1e-10 {
t.Fatalf("F round trip q = %g: CDF(quantile) = %v", q, fback)
}
}
// The t quantile leans towards δ.
neg, _ := NoncentralTQuantile(0.5, 5, -2)
if neg >= 0 {
t.Fatalf("median of t(5, −2) = %g, want negative", neg)
}
}
// TestNoncentralFMonteCarlo rebuilds the noncentral F from the
// package's own samplers: a χ²(df₁+2J) numerator with J drawn from the
// Poisson, over an independent central χ²(df₂) denominator, checked
// against the analytic CDF the mixture code computes. The sampler and
// the mixture share no code. Statistical tolerance 0.01, far above the
// 2σ of 50 000 draws.
func TestNoncentralFMonteCarlo(t *testing.T) {
g := core.NewGenerator(11)
const n = 50000
x := 2.0
got, err := NoncentralFCDF(x, 4, 10, 3)
if err != nil {
t.Fatalf("NoncentralFCDF: %v", err)
}
j, err := PoissonDraws(g, n, 1.5)
if err != nil {
t.Fatalf("PoissonDraws: %v", err)
}
count := 0.0
for i := range n {
df := min(
// The Poisson(1.5) tail never reaches 30; the fold is a
// contract guard, not a working branch.
4+2*int(j.FloatAt(i)), 64)
num, err := ChiSquareDraws(g, 1, df)
if err != nil {
t.Fatalf("ChiSquareDraws: %v", err)
}
den, err := ChiSquareDraws(g, 1, 10)
if err != nil {
t.Fatalf("ChiSquareDraws: %v", err)
}
f := num.FloatAt(0) * 10 / (4 * den.FloatAt(0))
if f <= x {
count++
}
}
if math.Abs(count/n-got) > 0.01 {
t.Fatalf("sampler CDF = %.4f, analytic %.4f", count/n, got)
}
}
// TestNoncentralChiSquareMonteCarlo rebuilds the noncentral χ² from
// PoissonDraws and ChiSquareDraws, the sampler route the mixture CDF
// has no code in common with, at a tolerance the 200 000 draws can
// carry.
func TestNoncentralChiSquareMonteCarlo(t *testing.T) {
g := core.NewGenerator(13)
const n = 200000
x := 6.0
got, err := NoncentralChiSquareCDF(x, 3, 4)
if err != nil {
t.Fatalf("NoncentralChiSquareCDF: %v", err)
}
j, err := PoissonDraws(g, n, 2)
if err != nil {
t.Fatalf("PoissonDraws: %v", err)
}
// One shared χ²(3) stream rescaled per draw would not follow
// χ²(3+2J), so the check walks the mixture identity the other way:
// P(χ²(3+2J) ≤ x) averaged over the drawn J equals the CDF.
total := 0.0
for i := range n {
df := min(3+2*int(j.FloatAt(i)), 400)
p, err := ChiSquareCDF(x, df)
if err != nil {
t.Fatalf("ChiSquareCDF: %v", err)
}
total += p
}
if math.Abs(total/n-got) > 0.01 {
t.Fatalf("sampler-route CDF = %v, want ≈ %v", total/n, got)
}
}
// TestNoncentralErrors pins the parameter contracts of the noncentral
// family.
func TestNoncentralErrors(t *testing.T) {
if _, err := NoncentralChiSquareCDF(1, 0, 1); err == nil {
t.Fatal("df = 0: want an error")
}
if _, err := NoncentralChiSquareCDF(1, 3, -1); err == nil {
t.Fatal("negative λ: want an error")
}
if _, err := NoncentralChiSquareCDF(1, 3, math.Inf(1)); err == nil {
t.Fatal("λ = +Inf: want an error")
}
if _, err := NoncentralChiSquareCDF(math.NaN(), 3, 1); err == nil {
t.Fatal("NaN x: want an error")
}
if _, err := NoncentralChiSquareDensity(1, 0, 1); err == nil {
t.Fatal("density df = 0: want an error")
}
if _, err := NoncentralChiSquareQuantile(0.5, 0, 1); err == nil {
t.Fatal("quantile df = 0: want an error")
}
if _, err := NoncentralChiSquareQuantile(1.5, 3, 1); err == nil {
t.Fatal("q outside [0, 1]: want an error")
}
if _, err := NoncentralChiSquareQuantile(0.5, 0, 1); err == nil {
t.Fatal("quantile df = 0: want an error")
}
if _, err := NoncentralChiSquareQuantile(0.5, 3, math.NaN()); err == nil {
t.Fatal("quantile NaN λ: want an error")
}
if v, err := NoncentralChiSquareCDF(math.Inf(1), 3, 2); err != nil || v != 1 {
t.Fatalf("CDF at +Inf = %v, %v, want 1", v, err)
}
if v, err := NoncentralChiSquareDensity(0, 1, 2); err != nil || !math.IsInf(v, 1) {
t.Fatalf("density at 0 with df = 1 = %v, %v, want +Inf", v, err)
}
if _, err := NoncentralChiSquareDensity(math.NaN(), 3, 2); err == nil {
t.Fatal("density NaN x: want an error")
}
if _, err := NoncentralFCDF(1, 0, 4, 1); err == nil {
t.Fatal("df1 = 0: want an error")
}
if _, err := NoncentralFCDF(1, 4, 0, 1); err == nil {
t.Fatal("df2 = 0: want an error")
}
if _, err := NoncentralFCDF(1, 4, 4, math.NaN()); err == nil {
t.Fatal("NaN λ: want an error")
}
if _, err := NoncentralFCDF(math.NaN(), 4, 4, 1); err == nil {
t.Fatal("NaN x: want an error")
}
if _, err := NoncentralFCDF(1, 4, 4, math.Inf(1)); err == nil {
t.Fatal("λ = +Inf: want an error")
}
if _, err := NoncentralFQuantile(0.5, 0, 4, 1); err == nil {
t.Fatal("quantile df1 = 0: want an error")
}
if _, err := NoncentralFQuantile(0.5, 4, 4, -1); err == nil {
t.Fatal("quantile negative λ: want an error")
}
if _, err := NoncentralFQuantile(0, 4, 4, 1); err == nil {
t.Fatal("q = 0: want an error")
}
if _, err := NoncentralTCDF(math.Inf(1), 3, 1); err == nil {
t.Fatal("t = +Inf: want an error")
}
if _, err := NoncentralTCDF(1, 3, math.NaN()); err == nil {
t.Fatal("NaN δ: want an error")
}
if _, err := NoncentralTQuantile(0.5, 0, 1); err == nil {
t.Fatal("quantile df = 0: want an error")
}
if _, err := NoncentralTQuantile(0.5, 3, math.Inf(-1)); err == nil {
t.Fatal("δ = −Inf: want an error")
}
if _, err := NoncentralTQuantile(1.5, 3, 1); err == nil {
t.Fatal("q above 1: want an error")
}
if _, err := NoncentralTQuantile(0, 3, 1); err == nil {
t.Fatal("q = 0: want an error")
}
if _, err := NoncentralTQuantile(1, 3, 1); err == nil {
t.Fatal("q = 1: want an error")
}
}