442 lines
16 KiB
Go
442 lines
16 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package stats
|
||
|
||
import (
|
||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||
)
|
||
|
||
import (
|
||
"math"
|
||
)
|
||
|
||
// Noncentral distributions: the χ², t and F laws with a noncentrality
|
||
// parameter, the laws the power of every test in this package runs on.
|
||
// The χ² is the Poisson mixture of its central family, the exact
|
||
// identity a noncentral χ²(ν, λ) draw is χ²(ν + 2J) with J a
|
||
// Poisson(λ/2) count; the F mixes the same numerator against the
|
||
// central denominator, whose pieces carry the scale (ν₁+2i)/ν₁. The
|
||
// noncentral t runs Lenth's algorithm
|
||
// (Applied Statistics 38, 1989, pages 185 to 189): the CDF as a sum of
|
||
// even terms, the Poisson-weighted I_x(j+½, ν/2) of the folded |t|,
|
||
// and odd terms, the half-normal-weighted I_x(j+1, ν/2) that carry the
|
||
// sign of δ, over x = t²/(t²+ν). Every series here sums positive
|
||
// well-scaled terms until the remaining Poisson mass bounds the
|
||
// truncation below the floor, and reports an error rather than a
|
||
// silently truncated value if the budget runs out. The test file holds
|
||
// all three against a direct quadrature of E[Φ(t√(V/ν) − δ)] and
|
||
// against the closed forms of the degenerate corners.
|
||
|
||
// noncentralTermFloor bounds the weight a mixture term may still carry
|
||
// when the sum stops: the remaining Poisson mass is below it, so the
|
||
// omitted tail cannot reach the 15th digit of the answer.
|
||
const noncentralTermFloor = 1e-18
|
||
|
||
// maxNoncentralTerms bounds the mixture loops. The weights peak at the
|
||
// index ⌊λ/2⌋ and the walk needs the peak plus a few standard
|
||
// deviations of Poisson spread to cross it, so the budget carries
|
||
// noncentralities up to roughly 2·10⁵ in the χ² and F and δ up to
|
||
// about 440 in the t; beyond that the refusal is explicit, and so is
|
||
// every weight the format cannot hold: they are computed term by term
|
||
// in log space, never climbed from a seed that could underflow to
|
||
// zero and take the whole sum with it.
|
||
const maxNoncentralTerms = 100000
|
||
|
||
// noncentralPoissonWeight is the Poisson(half) weight of the index i,
|
||
// computed term by term in log space. The multiplicative climb from
|
||
// the e^{−half} seed the series definitions start from underflows to
|
||
// an exact zero once half passes about 745, and a zero seed never
|
||
// recovers: every later weight would stay zero while the loop believed
|
||
// it had converged. Evaluating each weight from its own logarithm
|
||
// keeps the terms near the peak exact at any half the budget can walk
|
||
// past, and the genuinely negligible ones answer zero, which is what
|
||
// they are.
|
||
func noncentralPoissonWeight(half float64, i int) float64 {
|
||
if half == 0 {
|
||
if i == 0 {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
return math.Exp(-half + float64(i)*math.Log(half) - logGamma(float64(i)+1))
|
||
}
|
||
|
||
// noncentralBudgetRefused reports the explicit refusal when the weight
|
||
// peak of a Poisson(half) mixture sits past the term budget, where the
|
||
// walk would stop early with a wrong answer instead.
|
||
func noncentralBudgetRefused(name string, half float64) error {
|
||
return base.Errf("%s: the weight peak at %d needs a walk past the %d-term budget; the mixture answers only up to that noncentrality",
|
||
name, int(math.Floor(half)), maxNoncentralTerms)
|
||
}
|
||
|
||
// noncentralPeakInsideBudget reports whether the Poisson weight peak
|
||
// at ⌊half⌋ plus its spread sits inside the term budget.
|
||
func noncentralPeakInsideBudget(half float64) bool {
|
||
peak := math.Floor(half)
|
||
return float64(maxNoncentralTerms) >= peak+8*math.Sqrt(peak)+2
|
||
}
|
||
|
||
// noncentralOddWeight is the j-th half-normal weight of Lenth's odd
|
||
// series, δ·λ^j·p_0/(√(2π)·(2j+1)!!) with λ = δ² and p_0 the j = 0
|
||
// Poisson weight, carrying the sign of δ. The double factorial comes
|
||
// out of its log-space form (2j+1)!! = 2^{j+1}Γ(j+3/2)/√π, so the
|
||
// weight is computed from its own logarithm like the even part's and
|
||
// underflows only once it is genuinely negligible.
|
||
func noncentralOddWeight(shift, half float64, j int) float64 {
|
||
if shift == 0 {
|
||
return 0
|
||
}
|
||
lambda := shift * shift
|
||
if lambda == 0 {
|
||
return 0
|
||
}
|
||
ln := math.Log(math.Abs(shift)) + float64(j)*math.Log(lambda) - half -
|
||
(float64(j)+1.5)*math.Ln2 - logGamma(float64(j)+1.5)
|
||
return math.Copysign(math.Exp(ln), shift)
|
||
}
|
||
|
||
// NoncentralChiSquareCDF returns P(X ≤ x) for X ~ χ²(ν, λ), the
|
||
// Poisson(λ/2) mixture of central χ²(ν + 2i) CDFs, each through the
|
||
// existing GammaLower. The noncentrality λ must be non-negative and
|
||
// finite; λ = 0 answers through ChiSquareCDF exactly.
|
||
func NoncentralChiSquareCDF(x float64, df int, lambda float64) (float64, error) {
|
||
const name = "NoncentralChiSquareCDF"
|
||
if df < 1 {
|
||
return 0, base.Errf("%s: df must be ≥ 1, got %d", name, df)
|
||
}
|
||
if math.IsNaN(lambda) || lambda < 0 || math.IsInf(lambda, 0) {
|
||
return 0, base.Errf("%s: lambda must be finite and non-negative, got %g", name, lambda)
|
||
}
|
||
if math.IsNaN(x) {
|
||
return 0, base.Errf("%s: x must be a number, got %g", name, x)
|
||
}
|
||
if x <= 0 {
|
||
return 0, nil
|
||
}
|
||
if lambda == 0 {
|
||
return ChiSquareCDF(x, df)
|
||
}
|
||
return noncentralPoissonMixture(name, df, lambda,
|
||
func(i int) (float64, error) {
|
||
g, err := GammaLower(float64(df)/2+float64(i), x/2)
|
||
if err != nil {
|
||
return 0, base.Errf("%s: %w", name, err)
|
||
}
|
||
return g, nil
|
||
})
|
||
}
|
||
|
||
// NoncentralChiSquareDensity returns the χ²(ν, λ) density at x, the
|
||
// same Poisson mixture with central χ² densities, each carrying a
|
||
// closed exponential-power form. The support convention gives 0 below
|
||
// x = 0; at x = 0 with df = 1 the density is the +Inf the
|
||
// noncentralities preserve, with df = 2 it is the finite limit
|
||
// e^{−λ/2}/2 of the j = 0 mixture term, and past df = 2 it is 0.
|
||
func NoncentralChiSquareDensity(x float64, df int, lambda float64) (float64, error) {
|
||
const name = "NoncentralChiSquareDensity"
|
||
if df < 1 {
|
||
return 0, base.Errf("%s: df must be ≥ 1, got %d", name, df)
|
||
}
|
||
if math.IsNaN(lambda) || lambda < 0 || math.IsInf(lambda, 0) {
|
||
return 0, base.Errf("%s: lambda must be finite and non-negative, got %g", name, lambda)
|
||
}
|
||
if math.IsNaN(x) {
|
||
return 0, base.Errf("%s: x must be a number, got %g", name, x)
|
||
}
|
||
if x < 0 || (x == 0 && df > 2) {
|
||
return 0, nil
|
||
}
|
||
if x == 0 {
|
||
// df = 1: the x^{−½} singularity the mixture integrates; df = 2:
|
||
// the j = 0 term is finite at the origin and its limit
|
||
// e^{−λ/2}/2 is the answer.
|
||
if df == 2 {
|
||
return 0.5 * math.Exp(-lambda/2), nil
|
||
}
|
||
return math.Inf(1), nil
|
||
}
|
||
lambdaHalf := lambda / 2
|
||
if !noncentralPeakInsideBudget(lambdaHalf) {
|
||
return 0, noncentralBudgetRefused(name, lambdaHalf)
|
||
}
|
||
total := 0.0
|
||
for i := range maxNoncentralTerms {
|
||
weight := noncentralPoissonWeight(lambdaHalf, i)
|
||
a := float64(df)/2 + float64(i)
|
||
density := weight * math.Exp(-x/2+(a-1)*math.Log(x)-a*math.Ln2-logGamma(a))
|
||
total += density
|
||
if float64(i) > lambdaHalf+1 && weight < noncentralTermFloor {
|
||
return total, nil
|
||
}
|
||
}
|
||
return 0, base.Errf("%s: the mixture did not converge within %d terms for lambda = %g",
|
||
name, maxNoncentralTerms, lambda)
|
||
}
|
||
|
||
// NoncentralChiSquareQuantile returns the q-quantile of χ²(ν, λ) by
|
||
// the same bracketed bisection the central tables use, seeded near the
|
||
// mean ν + λ.
|
||
func NoncentralChiSquareQuantile(q float64, df int, lambda float64) (float64, error) {
|
||
if df < 1 {
|
||
return 0, base.Errf("NoncentralChiSquareQuantile: df must be ≥ 1, got %d", df)
|
||
}
|
||
if math.IsNaN(lambda) || lambda < 0 || math.IsInf(lambda, 0) {
|
||
return 0, base.Errf("NoncentralChiSquareQuantile: lambda must be finite and non-negative, got %g", lambda)
|
||
}
|
||
return continuousQuantile("NoncentralChiSquareQuantile", q, float64(df)+lambda/2,
|
||
func(x float64) (float64, error) {
|
||
return NoncentralChiSquareCDF(x, df, lambda)
|
||
}, nil)
|
||
}
|
||
|
||
// NoncentralFCDF returns P(X ≤ x) for X ~ F(ν₁, ν₂, λ), the numerator
|
||
// χ²(ν₁, λ) carried against the central denominator: the Poisson(λ/2)
|
||
// mixture of the scaled central pieces (ν₁+2i)/ν₁·F(ν₁+2i, ν₂), whose
|
||
// beta form sums I_{ν₁x/(ν₁x+ν₂)}((ν₁+2i)/2, ν₂/2) over the weights,
|
||
// through the existing BetaIncomplete. The λ = 0 corner is the central
|
||
// F exactly.
|
||
func NoncentralFCDF(x float64, df1, df2 int, lambda float64) (float64, error) {
|
||
const name = "NoncentralFCDF"
|
||
if df1 < 1 || df2 < 1 {
|
||
return 0, base.Errf("%s: df1 and df2 must be ≥ 1, got %d and %d", name, df1, df2)
|
||
}
|
||
if math.IsNaN(lambda) || lambda < 0 || math.IsInf(lambda, 0) {
|
||
return 0, base.Errf("%s: lambda must be finite and non-negative, got %g", name, lambda)
|
||
}
|
||
if math.IsNaN(x) {
|
||
return 0, base.Errf("%s: x must be a number, got %g", name, x)
|
||
}
|
||
if math.IsInf(x, 1) {
|
||
return 1, nil
|
||
}
|
||
if x <= 0 {
|
||
return 0, nil
|
||
}
|
||
// The beta argument saturates at 1 for an x so large the product
|
||
// ν₁x overflows, which is the CDF's own limit there.
|
||
numerator := float64(df1) * x
|
||
arg := 1.0
|
||
if !math.IsInf(numerator, 1) {
|
||
arg = numerator / (numerator + float64(df2))
|
||
}
|
||
return noncentralPoissonMixture(name, df1, lambda,
|
||
func(i int) (float64, error) {
|
||
p, err := BetaIncomplete(arg, (float64(df1)+2*float64(i))/2, float64(df2)/2)
|
||
if err != nil {
|
||
return 0, base.Errf("%s: %w", name, err)
|
||
}
|
||
return p, nil
|
||
})
|
||
}
|
||
|
||
// NoncentralFQuantile returns the q-quantile of F(ν₁, ν₂, λ) by
|
||
// bracketed bisection, seeded at 1 in the neighbourhood of the F
|
||
// median.
|
||
func NoncentralFQuantile(q float64, df1, df2 int, lambda float64) (float64, error) {
|
||
if df1 < 1 || df2 < 1 {
|
||
return 0, base.Errf("NoncentralFQuantile: df1 and df2 must be ≥ 1, got %d and %d", df1, df2)
|
||
}
|
||
if math.IsNaN(lambda) || lambda < 0 || math.IsInf(lambda, 0) {
|
||
return 0, base.Errf("NoncentralFQuantile: lambda must be finite and non-negative, got %g", lambda)
|
||
}
|
||
return continuousQuantile("NoncentralFQuantile", q, 1, func(x float64) (float64, error) {
|
||
return NoncentralFCDF(x, df1, df2, lambda)
|
||
}, nil)
|
||
}
|
||
|
||
// noncentralPoissonMixture sums w_i·term(i) over the Poisson(λ/2)
|
||
// weights w_i, the shared engine of the noncentral χ² and F. All terms
|
||
// are positive, so the running sum carries no cancellation; the walk
|
||
// stops past the weight peak once the weights have sunk below the
|
||
// floor. The weights are computed term by term in log space, so no
|
||
// noncentrality the budget can reach underflows the walk.
|
||
func noncentralPoissonMixture(name string, df int, lambda float64,
|
||
term func(i int) (float64, error)) (float64, error) {
|
||
half := lambda / 2
|
||
if !noncentralPeakInsideBudget(half) {
|
||
return 0, noncentralBudgetRefused(name, half)
|
||
}
|
||
total := 0.0
|
||
for i := range maxNoncentralTerms {
|
||
weight := noncentralPoissonWeight(half, i)
|
||
t, err := term(i)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
total += t * weight
|
||
if float64(i) > half+1 && weight < noncentralTermFloor {
|
||
return total, nil
|
||
}
|
||
}
|
||
return 0, base.Errf("%s: the mixture did not converge within %d terms for lambda = %g",
|
||
name, maxNoncentralTerms, lambda)
|
||
}
|
||
|
||
// NoncentralTCDF returns P(T ≤ t) for T ~ t(ν, δ), through Lenth's
|
||
// even and odd series: the even part folds the law through |t|, the
|
||
// Poisson(δ²/2)-weighted beta ratios of the |t| event, and the odd
|
||
// part carries the sign of δ through the √(2/π)δ(δ²)^j/(2j+1)!!
|
||
// half-normal weights. The truncation floor is the remaining Poisson
|
||
// mass the error bound 2s(xodd − godd) tracks, the bound the published
|
||
// algorithm proves. δ = 0 answers through StudentTCDF exactly, and
|
||
// t = 0 through the closed corner Φ(−δ).
|
||
func NoncentralTCDF(t float64, df int, delta float64) (float64, error) {
|
||
const name = "NoncentralTCDF"
|
||
if df < 1 {
|
||
return 0, base.Errf("%s: df must be ≥ 1, got %d", name, df)
|
||
}
|
||
if math.IsNaN(t) || math.IsInf(t, 0) {
|
||
return 0, base.Errf("%s: t must be finite, got %g", name, t)
|
||
}
|
||
if math.IsNaN(delta) || math.IsInf(delta, 0) {
|
||
return 0, base.Errf("%s: delta must be finite, got %g", name, delta)
|
||
}
|
||
if delta == 0 {
|
||
return StudentTCDF(t, df)
|
||
}
|
||
// The series is derived on t ≥ 0; the reflection F(t; δ) =
|
||
// 1 − F(−t; −δ), an exact identity of the law, covers the rest.
|
||
flipped := false
|
||
magnitude, shift := t, delta
|
||
if t < 0 {
|
||
flipped = true
|
||
magnitude = -t
|
||
shift = -delta
|
||
}
|
||
x := magnitude * magnitude / (magnitude*magnitude + float64(df))
|
||
if x == 0 {
|
||
// t = 0: the value collapses to Φ(−δ) exactly.
|
||
return NormalCDF(-delta), nil
|
||
}
|
||
lambda := shift * shift
|
||
half := lambda / 2
|
||
if !noncentralPeakInsideBudget(half) {
|
||
return 0, noncentralBudgetRefused(name, half)
|
||
}
|
||
// p_j are the Poisson(half) weights of the even part, q_j the
|
||
// half-normal weights of the odd part, q_j = δ·λ^j·p_0/(√(2π)·(2j+1)!!).
|
||
// Both are computed term by term in log space: the multiplicative
|
||
// climb from the e^{−λ/2} seed underflows to an exact zero once
|
||
// |δ| passes about 39, and a zero seed never recovers, which used
|
||
// to answer a silent 0 for the whole law.
|
||
p := 0.5 * noncentralPoissonWeight(half, 0)
|
||
q := noncentralOddWeight(shift, half, 0)
|
||
remaining := 0.5 - p
|
||
a := 0.5
|
||
b := float64(df) / 2
|
||
rxb := math.Pow(1-x, b)
|
||
// ln B(a, b) at a = ½.
|
||
lnBeta := 0.5*math.Log(math.Pi) + logGamma(b) - logGamma(a+b)
|
||
xodd, err := BetaIncomplete(x, a, b)
|
||
if err != nil {
|
||
return 0, base.Errf("%s: %w", name, err)
|
||
}
|
||
// godd and geven are the beta-integral pieces the recurrences peel
|
||
// off xodd and xeven, the subtraction forms of I_x(a+1, b) and
|
||
// I_x(a, b+1): one beta evaluation seeds the whole walk.
|
||
godd := 2 * rxb * math.Exp(a*math.Log(x)-lnBeta)
|
||
xeven := 1 - rxb
|
||
geven := b * x * rxb
|
||
total := p*xodd + q*xeven
|
||
for en := 1.0; en <= maxNoncentralTerms; en++ {
|
||
a++
|
||
xodd -= godd
|
||
xeven -= geven
|
||
godd *= x * (a + b - 1) / a
|
||
geven *= x * (a + b - 0.5) / (a + 0.5)
|
||
p = 0.5 * noncentralPoissonWeight(half, int(en))
|
||
q = noncentralOddWeight(shift, half, int(en))
|
||
remaining -= p
|
||
total += p*xodd + q*xeven
|
||
if bound := 2 * remaining * (xodd - godd); bound <= noncentralTermFloor {
|
||
total += NormalCDF(-shift)
|
||
if flipped {
|
||
total = 1 - total
|
||
}
|
||
return min(1, max(0, total)), nil
|
||
}
|
||
}
|
||
return 0, base.Errf("%s: the series did not converge within %d terms for delta = %g",
|
||
name, maxNoncentralTerms, delta)
|
||
}
|
||
|
||
// NoncentralTQuantile returns the q-quantile of t(ν, δ) by bracketed
|
||
// bisection on the signed axis: the law leans towards δ, so the
|
||
// bracket grows from the seed in both directions.
|
||
func NoncentralTQuantile(q float64, df int, delta float64) (float64, error) {
|
||
if df < 1 {
|
||
return 0, base.Errf("NoncentralTQuantile: df must be ≥ 1, got %d", df)
|
||
}
|
||
if math.IsNaN(delta) || math.IsInf(delta, 0) {
|
||
return 0, base.Errf("NoncentralTQuantile: delta must be finite, got %g", delta)
|
||
}
|
||
return signedQuantile("NoncentralTQuantile", q, math.Abs(delta)+1,
|
||
func(t float64) (float64, error) {
|
||
return NoncentralTCDF(t, df, delta)
|
||
})
|
||
}
|
||
|
||
// signedQuantile inverts a continuous CDF over the whole real axis,
|
||
// the signed twin of continuousQuantile: the bracket starts at ±seed
|
||
// and doubles outwards until the CDF straddles q, then halves to
|
||
// rounding level under the same unconditional convergence.
|
||
func signedQuantile(name string, q float64, seed float64,
|
||
cdf func(float64) (float64, error)) (float64, error) {
|
||
// NaN-rejecting on purpose, as in continuousQuantile.
|
||
if !(q >= 0 && q <= 1) {
|
||
return 0, base.Errf("%s: q must lie in [0, 1], got %g", name, q)
|
||
}
|
||
if q == 0 || q == 1 {
|
||
return 0, base.Errf("%s: q = %g has no finite quantile", name, q)
|
||
}
|
||
lo, hi := -seed, seed
|
||
fLo, err := cdf(lo)
|
||
if err != nil {
|
||
return 0, base.Errf("%s: %w", name, err)
|
||
}
|
||
fHi, err := cdf(hi)
|
||
if err != nil {
|
||
return 0, base.Errf("%s: %w", name, err)
|
||
}
|
||
for fLo > q {
|
||
lo *= 2
|
||
if math.IsInf(lo, 0) {
|
||
return 0, base.Errf("%s: failed to bracket q = %g from below", name, q)
|
||
}
|
||
if fLo, err = cdf(lo); err != nil {
|
||
return 0, base.Errf("%s: %w", name, err)
|
||
}
|
||
}
|
||
for fHi < q {
|
||
hi *= 2
|
||
if math.IsInf(hi, 0) {
|
||
return 0, base.Errf("%s: failed to bracket q = %g from above", name, q)
|
||
}
|
||
if fHi, err = cdf(hi); err != nil {
|
||
return 0, base.Errf("%s: %w", name, err)
|
||
}
|
||
}
|
||
converged := false
|
||
for range 4096 {
|
||
mid := (lo + hi) / 2
|
||
if mid == lo || mid == hi {
|
||
converged = true
|
||
break
|
||
}
|
||
f, err := cdf(mid)
|
||
if err != nil {
|
||
return 0, base.Errf("%s: %w", name, err)
|
||
}
|
||
if f < q {
|
||
lo = mid
|
||
} else {
|
||
hi = mid
|
||
}
|
||
}
|
||
if !converged {
|
||
return 0, base.Errf("%s: the bisection for q = %g did not converge", name, q)
|
||
}
|
||
return (lo + hi) / 2, nil
|
||
}
|