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 (
|
|
|
|
|
|
"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
|
|
|
|
|
|
}
|
2026-09-27 17:03:36 +02:00
|
|
|
|
magnitude2 := magnitude * magnitude
|
|
|
|
|
|
x := magnitude2 / (magnitude2 + float64(df))
|
|
|
|
|
|
if math.IsInf(magnitude2, 1) {
|
|
|
|
|
|
// A finite t whose square overflows drove the quotient through
|
|
|
|
|
|
// Inf/Inf into a NaN the incomplete beta refused under its own
|
|
|
|
|
|
// name. The beta argument's limit there is exactly 1.
|
|
|
|
|
|
x = 1
|
|
|
|
|
|
}
|
2026-09-03 10:00:00 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|