Files
tensor/stats/noncentral.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

442 lines
16 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 (
"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
}