736 lines
23 KiB
Go
736 lines
23 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"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Distribution functions: cumulative distribution functions and their
|
|||
|
|
// quantiles for the distributions the library draws from, so a
|
|||
|
|
// p-value or a confidence interval needs no second library.
|
|||
|
|
//
|
|||
|
|
// The foundations are the regularised incomplete gamma and beta
|
|||
|
|
// functions: every continuous CDF here reduces to one of them, and
|
|||
|
|
// the two discrete CDFs reduce through the exact identities
|
|||
|
|
// P(Poisson ≤ k) = ΓUpper(k+1, λ) and P(Binomial ≤ k) =
|
|||
|
|
// BetaIncomplete(1−p, n−k, k+1), which hold in floating point to the
|
|||
|
|
// accuracy of the incomplete functions themselves. Quantiles invert
|
|||
|
|
// the CDF by a bracketed Newton iteration through the distribution's
|
|||
|
|
// density, falling back to bisection whenever the derivative step
|
|||
|
|
// would leave the bracket, so the convergence stays unconditional on
|
|||
|
|
// every monotone CDF.
|
|||
|
|
|
|||
|
|
// gammaSeriesEps bounds the series and continued-fraction iterations
|
|||
|
|
// of the incomplete functions.
|
|||
|
|
const gammaSeriesEps = 3e-16
|
|||
|
|
|
|||
|
|
// gammaIterations bounds the series and continued-fraction iterations
|
|||
|
|
// for a shape a. Both converge in O(√a) rounds near x ≈ a, so a fixed
|
|||
|
|
// cap would silently truncate the large-shape answers (a shape of
|
|||
|
|
// 50000 at x ≈ a needs about 1800 series rounds); the budget grows
|
|||
|
|
// with √a and every caller errors when it is still not enough.
|
|||
|
|
func gammaIterations(a float64) int {
|
|||
|
|
return 1000 + int(20*math.Sqrt(a))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GammaLower returns the regularised lower incomplete gamma
|
|||
|
|
// P(a, x) = γ(a, x)/Γ(a), the CDF of a Gamma(shape a, rate 1) draw.
|
|||
|
|
// It evaluates the power series below x < a+1 and the continued
|
|||
|
|
// fraction of the complement above, each to rounding level; for large
|
|||
|
|
// shapes the series budget grows with √a, and either iteration that
|
|||
|
|
// fails to converge inside its budget is an error rather than a
|
|||
|
|
// silently truncated value.
|
|||
|
|
func GammaLower(a, x float64) (float64, error) {
|
|||
|
|
p, _, err := gammaIncomplete("GammaLower", a, x)
|
|||
|
|
return p, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GammaUpper returns the regularised upper incomplete gamma
|
|||
|
|
// Q(a, x) = Γ(a, x)/Γ(a) = 1 − P(a, x), computed on the same
|
|||
|
|
// foundation as GammaLower.
|
|||
|
|
func GammaUpper(a, x float64) (float64, error) {
|
|||
|
|
_, q, err := gammaIncomplete("GammaUpper", a, x)
|
|||
|
|
return q, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// gammaIncomplete returns P(a, x) and Q(a, x) together, sharing the
|
|||
|
|
// one exponent factor both need.
|
|||
|
|
func gammaIncomplete(name string, a, x float64) (p, q float64, err error) {
|
|||
|
|
if !(a > 0) {
|
|||
|
|
return 0, 0, base.Errf("%s: shape a must be positive, got %g", name, a)
|
|||
|
|
}
|
|||
|
|
if !(x >= 0) {
|
|||
|
|
return 0, 0, base.Errf("%s: x must not be negative, got %g", name, x)
|
|||
|
|
}
|
|||
|
|
if x == 0 {
|
|||
|
|
return 0, 1, nil
|
|||
|
|
}
|
|||
|
|
if math.IsInf(x, 1) {
|
|||
|
|
return 1, 0, nil
|
|||
|
|
}
|
|||
|
|
lg, _ := math.Lgamma(a)
|
|||
|
|
front := math.Exp(-x + a*math.Log(x) - lg)
|
|||
|
|
// The iteration budget is fixed by the shape alone, so it is
|
|||
|
|
// computed once: the same bound the inline call evaluated, without
|
|||
|
|
// re-deriving it on every round.
|
|||
|
|
rounds := gammaIterations(a)
|
|||
|
|
if x < a+1 {
|
|||
|
|
// Power series for P: γ(a, x) = e^{−x}x^a Σ x^n/(a(a+1)…(a+n)).
|
|||
|
|
// Every term is positive, so the sum carries no cancellation and
|
|||
|
|
// stays accurate for arbitrarily large shapes given enough
|
|||
|
|
// rounds.
|
|||
|
|
sum := 1 / a
|
|||
|
|
del := sum
|
|||
|
|
ap := a
|
|||
|
|
converged := false
|
|||
|
|
for range rounds {
|
|||
|
|
ap++
|
|||
|
|
del *= x / ap
|
|||
|
|
sum += del
|
|||
|
|
if math.Abs(del) < math.Abs(sum)*gammaSeriesEps {
|
|||
|
|
converged = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !converged {
|
|||
|
|
return 0, 0, base.Errf("%s: the power series did not converge for a = %g, x = %g", name, a, x)
|
|||
|
|
}
|
|||
|
|
return sum * front, 1 - sum*front, nil
|
|||
|
|
}
|
|||
|
|
// Continued fraction for Q (Numerical Recipes form), Lentz's
|
|||
|
|
// modified method with the tiny floor.
|
|||
|
|
const fpmin = 1e-300
|
|||
|
|
b := x + 1 - a
|
|||
|
|
c := 1 / fpmin
|
|||
|
|
d := 1 / b
|
|||
|
|
h := d
|
|||
|
|
converged := false
|
|||
|
|
for i := 1; i <= rounds; i++ {
|
|||
|
|
an := -float64(i) * (float64(i) - a)
|
|||
|
|
b += 2
|
|||
|
|
d = an*d + b
|
|||
|
|
if math.Abs(d) < fpmin {
|
|||
|
|
d = fpmin
|
|||
|
|
}
|
|||
|
|
c = b + an/c
|
|||
|
|
if math.Abs(c) < fpmin {
|
|||
|
|
c = fpmin
|
|||
|
|
}
|
|||
|
|
d = 1 / d
|
|||
|
|
del := d * c
|
|||
|
|
h *= del
|
|||
|
|
if math.Abs(del-1) < gammaSeriesEps {
|
|||
|
|
converged = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !converged {
|
|||
|
|
return 0, 0, base.Errf("%s: the continued fraction did not converge for a = %g, x = %g", name, a, x)
|
|||
|
|
}
|
|||
|
|
return 1 - front*h, front * h, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BetaIncomplete returns the regularised incomplete beta function
|
|||
|
|
// I_x(a, b), the CDF of a Beta(a, b) draw, by the continued fraction
|
|||
|
|
// with the standard symmetry switch at x > (a+1)/(a+b+2).
|
|||
|
|
func BetaIncomplete(x, a, b float64) (float64, error) {
|
|||
|
|
if !(a > 0 && b > 0) {
|
|||
|
|
return 0, base.Errf("BetaIncomplete: shape parameters must be positive, got %g, %g", a, b)
|
|||
|
|
}
|
|||
|
|
if !(x >= 0 && x <= 1) {
|
|||
|
|
return 0, base.Errf("BetaIncomplete: x must lie in [0, 1], got %g", x)
|
|||
|
|
}
|
|||
|
|
if x == 0 || x == 1 {
|
|||
|
|
return x, nil
|
|||
|
|
}
|
|||
|
|
lab, _ := math.Lgamma(a + b)
|
|||
|
|
la, _ := math.Lgamma(a)
|
|||
|
|
lb, _ := math.Lgamma(b)
|
|||
|
|
front := math.Exp(lab - la - lb + a*math.Log(x) + b*math.Log(1-x))
|
|||
|
|
// The continued fraction converges fastest away from the skew side.
|
|||
|
|
if x < (a+1)/(a+b+2) {
|
|||
|
|
h, err := betaContinue(a, b, x)
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, base.Errf("BetaIncomplete: %w", err)
|
|||
|
|
}
|
|||
|
|
return front * h / a, nil
|
|||
|
|
}
|
|||
|
|
h, err := betaContinue(b, a, 1-x)
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, base.Errf("BetaIncomplete: %w", err)
|
|||
|
|
}
|
|||
|
|
return 1 - front*h/b, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// betaContinue evaluates the incomplete-beta continued fraction
|
|||
|
|
// (Numerical Recipes form) by Lentz's modified method. The round
|
|||
|
|
// budget grows with the shapes exactly as the incomplete gamma's does,
|
|||
|
|
// and an iteration that fails to converge inside it is an error rather
|
|||
|
|
// than a silently truncated value.
|
|||
|
|
func betaContinue(a, b, x float64) (float64, error) {
|
|||
|
|
const fpmin = 1e-300
|
|||
|
|
rounds := max(1000, gammaIterations(a)+gammaIterations(b))
|
|||
|
|
qab := a + b
|
|||
|
|
qap := a + 1
|
|||
|
|
qam := a - 1
|
|||
|
|
c := 1.0
|
|||
|
|
d := 1 - qab*x/qap
|
|||
|
|
if math.Abs(d) < fpmin {
|
|||
|
|
d = fpmin
|
|||
|
|
}
|
|||
|
|
d = 1 / d
|
|||
|
|
h := d
|
|||
|
|
converged := false
|
|||
|
|
for i := 1; i <= rounds; i++ {
|
|||
|
|
m := float64(i)
|
|||
|
|
m2 := 2 * m
|
|||
|
|
aa := m * (b - m) * x / ((qam + m2) * (a + m2))
|
|||
|
|
d = 1 + aa*d
|
|||
|
|
if math.Abs(d) < fpmin {
|
|||
|
|
d = fpmin
|
|||
|
|
}
|
|||
|
|
c = 1 + aa/c
|
|||
|
|
if math.Abs(c) < fpmin {
|
|||
|
|
c = fpmin
|
|||
|
|
}
|
|||
|
|
d = 1 / d
|
|||
|
|
h *= d * c
|
|||
|
|
aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
|
|||
|
|
d = 1 + aa*d
|
|||
|
|
if math.Abs(d) < fpmin {
|
|||
|
|
d = fpmin
|
|||
|
|
}
|
|||
|
|
c = 1 + aa/c
|
|||
|
|
if math.Abs(c) < fpmin {
|
|||
|
|
c = fpmin
|
|||
|
|
}
|
|||
|
|
d = 1 / d
|
|||
|
|
del := d * c
|
|||
|
|
h *= del
|
|||
|
|
if math.Abs(del-1) < gammaSeriesEps {
|
|||
|
|
converged = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !converged {
|
|||
|
|
return 0, base.Errf("the continued fraction did not converge for a = %g, b = %g, x = %g", a, b, x)
|
|||
|
|
}
|
|||
|
|
return h, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NormalCDF returns Φ(x), the standard normal cumulative distribution.
|
|||
|
|
func NormalCDF(x float64) float64 {
|
|||
|
|
return 0.5 * math.Erfc(-x/math.Sqrt2)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ExponentialCDF returns P(X ≤ x) for X ~ Exponential(rate).
|
|||
|
|
func ExponentialCDF(x, rate float64) (float64, error) {
|
|||
|
|
if !(rate > 0) {
|
|||
|
|
return 0, base.Errf("ExponentialCDF: rate must be positive, got %g", rate)
|
|||
|
|
}
|
|||
|
|
if math.IsNaN(x) {
|
|||
|
|
// The x ≤ 0 branch below is the left tail, not a rejection, so
|
|||
|
|
// NaN needs its own guard: without it the answer is a silent NaN.
|
|||
|
|
return 0, base.Errf("ExponentialCDF: x must be a number, got %g", x)
|
|||
|
|
}
|
|||
|
|
if x <= 0 {
|
|||
|
|
return 0, nil
|
|||
|
|
}
|
|||
|
|
// −Expm1(−rate·x) is 1 − e^{−rate·x} without the cancellation: the
|
|||
|
|
// literal form loses the left tail, an 11 % relative error already
|
|||
|
|
// at rate·x = 1e-16 and a total loss not far below, while Expm1
|
|||
|
|
// keeps every bit of the small answer.
|
|||
|
|
return -math.Expm1(-rate * x), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GammaCDF returns P(X ≤ x) for X ~ Gamma(shape, rate), the same
|
|||
|
|
// parametrisation GammaDraws samples.
|
|||
|
|
func GammaCDF(x, shape, rate float64) (float64, error) {
|
|||
|
|
if !(shape > 0 && rate > 0) {
|
|||
|
|
return 0, base.Errf("GammaCDF: shape and rate must be positive, got %g, %g", shape, rate)
|
|||
|
|
}
|
|||
|
|
return GammaLower(shape, x*rate)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ChiSquareCDF returns P(X ≤ x) for X ~ χ²(df).
|
|||
|
|
func ChiSquareCDF(x float64, df int) (float64, error) {
|
|||
|
|
if df < 1 {
|
|||
|
|
return 0, base.Errf("ChiSquareCDF: df must be ≥ 1, got %d", df)
|
|||
|
|
}
|
|||
|
|
return GammaLower(float64(df)/2, x/2)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// StudentTCDF returns P(T ≤ t) for T ~ Student t(df).
|
|||
|
|
func StudentTCDF(t float64, df int) (float64, error) {
|
|||
|
|
if df < 1 {
|
|||
|
|
return 0, base.Errf("StudentTCDF: df must be ≥ 1, got %d", df)
|
|||
|
|
}
|
|||
|
|
z := float64(df) / (float64(df) + t*t)
|
|||
|
|
upper, err := BetaIncomplete(z, float64(df)/2, 0.5)
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, base.Errf("StudentTCDF: %w", err)
|
|||
|
|
}
|
|||
|
|
if t >= 0 {
|
|||
|
|
return 1 - upper/2, nil
|
|||
|
|
}
|
|||
|
|
return upper / 2, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// PoissonCDF returns P(N ≤ k) for N ~ Poisson(lambda), through the
|
|||
|
|
// identity P(N ≤ k) = ΓUpper(k+1, λ).
|
|||
|
|
func PoissonCDF(k int, lambda float64) (float64, error) {
|
|||
|
|
if !(lambda > 0) {
|
|||
|
|
return 0, base.Errf("PoissonCDF: lambda must be positive, got %g", lambda)
|
|||
|
|
}
|
|||
|
|
if k < 0 {
|
|||
|
|
return 0, nil
|
|||
|
|
}
|
|||
|
|
return GammaUpper(float64(k+1), lambda)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BinomialCDF returns P(X ≤ k) for X ~ Binomial(trials, p), through
|
|||
|
|
// the identity P(X ≤ k) = I_{1−p}(n−k, k+1).
|
|||
|
|
func BinomialCDF(k, trials int, p float64) (float64, error) {
|
|||
|
|
if trials < 1 {
|
|||
|
|
return 0, base.Errf("BinomialCDF: trials must be ≥ 1, got %d", trials)
|
|||
|
|
}
|
|||
|
|
if !(p > 0 && p < 1) {
|
|||
|
|
return 0, base.Errf("BinomialCDF: p must lie in (0, 1), got %g", p)
|
|||
|
|
}
|
|||
|
|
if k < 0 {
|
|||
|
|
return 0, nil
|
|||
|
|
}
|
|||
|
|
if k >= trials {
|
|||
|
|
return 1, nil
|
|||
|
|
}
|
|||
|
|
return BetaIncomplete(1-p, float64(trials-k), float64(k+1))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// continuousQuantile inverts a continuous CDF on the positive axis:
|
|||
|
|
// the bracket starts at seed and doubles outward until the CDF
|
|||
|
|
// straddles q, then the crossing is refined by a bracketed Newton
|
|||
|
|
// iteration through the CDF's derivative pdf when one is given, and
|
|||
|
|
// by plain bisection when it is not. The Newton walk carries the
|
|||
|
|
// bracket with it and bisects whenever the derivative step would
|
|||
|
|
// leave the bracket or would not cut it fast enough, so the
|
|||
|
|
// convergence stays unconditional on every monotone CDF either way.
|
|||
|
|
func continuousQuantile(name string, q float64, seed float64,
|
|||
|
|
cdf func(float64) (float64, error), pdf func(float64) float64) (float64, error) {
|
|||
|
|
// The guard is NaN-rejecting on purpose: NaN compares false against
|
|||
|
|
// both bounds, so the written-out form would let it past and every
|
|||
|
|
// bracket comparison below would then also be false.
|
|||
|
|
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)
|
|||
|
|
}
|
|||
|
|
// Establish a bracket where the CDF crosses q.
|
|||
|
|
for fLo > q {
|
|||
|
|
lo /= 2
|
|||
|
|
// Halving from a finite seed underflows through the denormals
|
|||
|
|
// to exactly zero, which is the whole representable range: no
|
|||
|
|
// CDF value can sit below a probability of that size.
|
|||
|
|
if 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)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if pdf == nil {
|
|||
|
|
// Halve to rounding level. The interval at least halves every pass
|
|||
|
|
// and the loop leaves on mid == lo || mid == hi, which a finite
|
|||
|
|
// bracket always reaches (about 1100 passes from the widest one),
|
|||
|
|
// so the iteration count is a guard against a non-monotone CDF,
|
|||
|
|
// not a working part of the convergence: hitting it is an error,
|
|||
|
|
// never a quietly unconverged midpoint.
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
return continuousQuantileNewton(name, q, lo, hi, cdf, pdf)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// continuousQuantileNewton refines a bracket where the CDF crosses q
|
|||
|
|
// by a safeguarded Newton walk: a Newton step on cdf(x) = q through
|
|||
|
|
// the pdf derivative whenever that step lands inside the bracket and
|
|||
|
|
// cuts it at least as fast as the bisection it replaces, a bisection
|
|||
|
|
// step otherwise. The bisection guarantee survives untouched: the
|
|||
|
|
// bracket at least halves every second round, the walk leaves when the
|
|||
|
|
// bracket has no float strictly inside it, the same rounding-level
|
|||
|
|
// exit the plain bisection takes, and a vanishing or non-finite
|
|||
|
|
// derivative fails both Newton guards and bisects.
|
|||
|
|
func continuousQuantileNewton(name string, q float64, lo, hi float64,
|
|||
|
|
cdf func(float64) (float64, error), pdf func(float64) float64) (float64, error) {
|
|||
|
|
x := 0.5 * (lo + hi)
|
|||
|
|
f, err := cdf(x)
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, base.Errf("%s: %w", name, err)
|
|||
|
|
}
|
|||
|
|
if f == q {
|
|||
|
|
return x, nil
|
|||
|
|
}
|
|||
|
|
if f < q {
|
|||
|
|
lo = x
|
|||
|
|
} else {
|
|||
|
|
hi = x
|
|||
|
|
}
|
|||
|
|
// stepSize carries the previous round's step, the yardstick the
|
|||
|
|
// second Newton guard measures against: a derivative step that does
|
|||
|
|
// not halve it is stalling, and the round bisects instead.
|
|||
|
|
stepSize := hi - lo
|
|||
|
|
converged := false
|
|||
|
|
for range 4096 {
|
|||
|
|
d := pdf(x)
|
|||
|
|
g := f - q
|
|||
|
|
// The two rtsafe guards: the derivative step is taken only when
|
|||
|
|
// it lands strictly inside the bracket, x - g/d in (lo, hi),
|
|||
|
|
// and cuts the last step at least in half. A vanishing or
|
|||
|
|
// non-finite derivative fails the first guard and bisects.
|
|||
|
|
newton := d > 0 && !math.IsInf(d, 1) &&
|
|||
|
|
(x-lo)*d > g && g > (x-hi)*d &&
|
|||
|
|
2*math.Abs(g) <= math.Abs(stepSize*d)
|
|||
|
|
if newton {
|
|||
|
|
stepSize = g / d
|
|||
|
|
x -= stepSize
|
|||
|
|
} else {
|
|||
|
|
stepSize = 0.5 * (hi - lo)
|
|||
|
|
x = 0.5 * (lo + hi)
|
|||
|
|
}
|
|||
|
|
if x == lo || x == hi {
|
|||
|
|
converged = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
if f, err = cdf(x); err != nil {
|
|||
|
|
return 0, base.Errf("%s: %w", name, err)
|
|||
|
|
}
|
|||
|
|
if f == q {
|
|||
|
|
converged = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
if f < q {
|
|||
|
|
lo = x
|
|||
|
|
} else {
|
|||
|
|
hi = x
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !converged {
|
|||
|
|
return 0, base.Errf("%s: the quantile iteration for q = %g did not converge", name, q)
|
|||
|
|
}
|
|||
|
|
return x, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// continuousQuantileUpper inverts a monotone decreasing upper-tail
|
|||
|
|
// probability on the positive axis: it returns the z > 0 with
|
|||
|
|
// upper(z) = q, for q below what the reflected CDF can represent
|
|||
|
|
// (below 2⁻⁵³, where 1−q rounds to 1). The bracket starts at [0, 1]
|
|||
|
|
// and doubles outward; upper(0) = 0.5 covers every such q.
|
|||
|
|
func continuousQuantileUpper(name string, q float64,
|
|||
|
|
upper func(float64) (float64, error)) (float64, error) {
|
|||
|
|
lo, hi := 0.0, 1.0
|
|||
|
|
fHi, err := upper(hi)
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, base.Errf("%s: %w", name, err)
|
|||
|
|
}
|
|||
|
|
for fHi > q {
|
|||
|
|
lo = hi
|
|||
|
|
hi *= 2
|
|||
|
|
if math.IsInf(hi, 0) {
|
|||
|
|
return 0, base.Errf("%s: failed to bracket q = %g from above", name, q)
|
|||
|
|
}
|
|||
|
|
if fHi, err = upper(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 := upper(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
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// discreteQuantile returns the smallest integer k whose CDF reaches
|
|||
|
|
// q, found by doubling the upper bound then bisecting the integer
|
|||
|
|
// grid. The result is a whole number in float form.
|
|||
|
|
func discreteQuantile(name string, q float64, cdf func(int) (float64, error)) (float64, error) {
|
|||
|
|
// NaN-rejecting for the same reason continuousQuantile's guard is:
|
|||
|
|
// a NaN q makes every comparison below false, and the doubling
|
|||
|
|
// bracket would then run without end.
|
|||
|
|
if !(q >= 0 && q <= 1) {
|
|||
|
|
return 0, base.Errf("%s: q must lie in [0, 1], got %g", name, q)
|
|||
|
|
}
|
|||
|
|
if q == 0 {
|
|||
|
|
return 0, nil
|
|||
|
|
}
|
|||
|
|
hi := 1
|
|||
|
|
for {
|
|||
|
|
f, err := cdf(hi)
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, base.Errf("%s: %w", name, err)
|
|||
|
|
}
|
|||
|
|
if f >= q {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
// A monotone CDF reaches any q > 0 at some finite k, so a bound
|
|||
|
|
// this high means the CDF never will: refuse rather than let hi
|
|||
|
|
// wrap to zero and the doubling loop spin for ever.
|
|||
|
|
if hi > math.MaxInt/2 {
|
|||
|
|
return 0, base.Errf("%s: failed to bracket q = %g", name, q)
|
|||
|
|
}
|
|||
|
|
hi *= 2
|
|||
|
|
}
|
|||
|
|
lo := 0
|
|||
|
|
for lo < hi {
|
|||
|
|
mid := (lo + hi) / 2
|
|||
|
|
f, err := cdf(mid)
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, base.Errf("%s: %w", name, err)
|
|||
|
|
}
|
|||
|
|
if f >= q {
|
|||
|
|
hi = mid
|
|||
|
|
} else {
|
|||
|
|
lo = mid + 1
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return float64(lo), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// normalPdf is the standard normal density, the derivative
|
|||
|
|
// NormalQuantile's Newton walk steps through.
|
|||
|
|
func normalPdf(x float64) float64 {
|
|||
|
|
return math.Exp(-0.5*x*x) / math.Sqrt(2*math.Pi)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// gammaShapeRatePdf is the Gamma(shape, rate) density at x, evaluated
|
|||
|
|
// from its logarithm so a large shape's normalising factor never
|
|||
|
|
// overflows on its way to a density the format cannot hold. x at or
|
|||
|
|
// below zero answers 0: the quantile walk treats a vanishing
|
|||
|
|
// derivative as a signal to bisect.
|
|||
|
|
func gammaShapeRatePdf(x, shape, rate float64) float64 {
|
|||
|
|
if x <= 0 {
|
|||
|
|
return 0
|
|||
|
|
}
|
|||
|
|
return math.Exp((shape-1)*math.Log(x) - rate*x + shape*math.Log(rate) - logGamma(shape))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// studentTPdf is the Student t(df) density at x, from its logarithm
|
|||
|
|
// like the gamma density, so the wide tails' underflow lands on the 0
|
|||
|
|
// the quantile walk reads as a bisection signal.
|
|||
|
|
func studentTPdf(x float64, df int) float64 {
|
|||
|
|
d := float64(df)
|
|||
|
|
return math.Exp(logGamma(0.5*(d+1)) - logGamma(0.5*d) -
|
|||
|
|
0.5*math.Log(d*math.Pi) - 0.5*(d+1)*math.Log1p(x*x/d))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NormalQuantile returns the q-quantile of the standard normal
|
|||
|
|
// distribution, the inverse of NormalCDF.
|
|||
|
|
func NormalQuantile(q float64) (float64, error) {
|
|||
|
|
if !(q >= 0 && q <= 1) {
|
|||
|
|
return 0, base.Errf("NormalQuantile: q must lie in [0, 1], got %g", q)
|
|||
|
|
}
|
|||
|
|
if q == 0 || q == 1 {
|
|||
|
|
return 0, base.Errf("NormalQuantile: q = %g has no finite quantile", q)
|
|||
|
|
}
|
|||
|
|
// The bracket expansion below only walks up from the seed, and
|
|||
|
|
// Phi(0) = 0.5 is the infimum it can reach, so the whole lower half
|
|||
|
|
// of the domain is bracketed on the reflected probability instead:
|
|||
|
|
// Phi is symmetric, so the lower-tail quantile is the mirror of the
|
|||
|
|
// upper one. Below 2⁻⁵³ the reflection 1−q rounds to exactly 1 and
|
|||
|
|
// would be read as the q = 1 refusal, so the extreme tail bisects
|
|||
|
|
// the accurate upper tail 0.5·Erfc directly, on the positive axis.
|
|||
|
|
if q < 0.5 {
|
|||
|
|
if 1-q == 1 {
|
|||
|
|
z, err := continuousQuantileUpper("NormalQuantile", q, func(x float64) (float64, error) {
|
|||
|
|
return 0.5 * math.Erfc(x/math.Sqrt2), nil
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, err
|
|||
|
|
}
|
|||
|
|
return -z, nil
|
|||
|
|
}
|
|||
|
|
z, err := continuousQuantile("NormalQuantile", 1-q, 1, func(x float64) (float64, error) {
|
|||
|
|
return NormalCDF(x), nil
|
|||
|
|
}, normalPdf)
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, err
|
|||
|
|
}
|
|||
|
|
return -z, nil
|
|||
|
|
}
|
|||
|
|
return continuousQuantile("NormalQuantile", q, 1, func(x float64) (float64, error) {
|
|||
|
|
return NormalCDF(x), nil
|
|||
|
|
}, normalPdf)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ExponentialQuantile returns the q-quantile of Exponential(rate).
|
|||
|
|
func ExponentialQuantile(q, rate float64) (float64, error) {
|
|||
|
|
return continuousQuantile("ExponentialQuantile", q, 1, func(x float64) (float64, error) {
|
|||
|
|
return ExponentialCDF(x, rate)
|
|||
|
|
}, func(x float64) float64 {
|
|||
|
|
return rate * math.Exp(-rate*x)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GammaQuantile returns the q-quantile of Gamma(shape, rate).
|
|||
|
|
func GammaQuantile(q, shape, rate float64) (float64, error) {
|
|||
|
|
return continuousQuantile("GammaQuantile", q, shape/rate, func(x float64) (float64, error) {
|
|||
|
|
return GammaCDF(x, shape, rate)
|
|||
|
|
}, func(x float64) float64 {
|
|||
|
|
return gammaShapeRatePdf(x, shape, rate)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ChiSquareQuantile returns the q-quantile of χ²(df), the critical
|
|||
|
|
// value tables quote.
|
|||
|
|
func ChiSquareQuantile(q float64, df int) (float64, error) {
|
|||
|
|
shape := float64(df) / 2
|
|||
|
|
return continuousQuantile("ChiSquareQuantile", q, float64(df), func(x float64) (float64, error) {
|
|||
|
|
return ChiSquareCDF(x, df)
|
|||
|
|
}, func(x float64) float64 {
|
|||
|
|
return gammaShapeRatePdf(x, shape, 0.5)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// StudentTQuantile returns the q-quantile of Student t(df). The signed
|
|||
|
|
// axis is bracketed through a two-sided bracket expansion, the crossing
|
|||
|
|
// refined by the bracketed Newton walk through the t density.
|
|||
|
|
func StudentTQuantile(q float64, df int) (float64, error) {
|
|||
|
|
if !(q >= 0 && q <= 1) {
|
|||
|
|
return 0, base.Errf("StudentTQuantile: q must lie in [0, 1], got %g", q)
|
|||
|
|
}
|
|||
|
|
if q == 0 || q == 1 {
|
|||
|
|
return 0, base.Errf("StudentTQuantile: q = %g has no finite quantile", q)
|
|||
|
|
}
|
|||
|
|
// Bracket on the positive side, then mirror for q < 0.5. Below 2⁻⁵³
|
|||
|
|
// the reflection 1−q rounds to exactly 1 and would be read as the
|
|||
|
|
// q = 1 refusal, so the extreme tail bisects the one-sided upper
|
|||
|
|
// tail directly, on the positive axis.
|
|||
|
|
if q < 0.5 {
|
|||
|
|
if 1-q == 1 {
|
|||
|
|
t, err := continuousQuantileUpper("StudentTQuantile", q, func(x float64) (float64, error) {
|
|||
|
|
return studentTUpperTail(x, df)
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, err
|
|||
|
|
}
|
|||
|
|
return -t, nil
|
|||
|
|
}
|
|||
|
|
t, err := continuousQuantile("StudentTQuantile", 1-q, 1, func(x float64) (float64, error) {
|
|||
|
|
return StudentTCDF(x, df)
|
|||
|
|
}, func(x float64) float64 {
|
|||
|
|
return studentTPdf(x, df)
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, err
|
|||
|
|
}
|
|||
|
|
return -t, nil
|
|||
|
|
}
|
|||
|
|
return continuousQuantile("StudentTQuantile", q, 1, func(x float64) (float64, error) {
|
|||
|
|
return StudentTCDF(x, df)
|
|||
|
|
}, func(x float64) float64 {
|
|||
|
|
return studentTPdf(x, df)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// studentTUpperTail returns P(T > t) for Student t(df), one tail of
|
|||
|
|
// the symmetric two-sided form twoSidedT uses. Beyond t²'s overflow
|
|||
|
|
// the incomplete-beta argument saturates to 0 and would report a
|
|||
|
|
// phantom zero tail exactly where the heavy df ≤ 2 tails stay
|
|||
|
|
// representable, so those degrees answer by their asymptotic forms.
|
|||
|
|
func studentTUpperTail(t float64, df int) (float64, error) {
|
|||
|
|
if t > 1.3e154 {
|
|||
|
|
switch {
|
|||
|
|
case df == 1:
|
|||
|
|
// Cauchy: the exact tail 1/2 − atan(t)/π, in a form the
|
|||
|
|
// huge t keeps accurate.
|
|||
|
|
return 1 / (math.Pi * t), nil
|
|||
|
|
case df == 2:
|
|||
|
|
return 1 / (2 * t * t), nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
z := float64(df) / (float64(df) + t*t)
|
|||
|
|
p, err := BetaIncomplete(z, float64(df)/2, 0.5)
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, err
|
|||
|
|
}
|
|||
|
|
return p / 2, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// PoissonQuantile returns the smallest k with P(N ≤ k) ≥ q for
|
|||
|
|
// N ~ Poisson(lambda).
|
|||
|
|
func PoissonQuantile(q, lambda float64) (float64, error) {
|
|||
|
|
if !(lambda > 0) {
|
|||
|
|
return 0, base.Errf("PoissonQuantile: lambda must be positive, got %g", lambda)
|
|||
|
|
}
|
|||
|
|
return discreteQuantile("PoissonQuantile", q, func(k int) (float64, error) {
|
|||
|
|
return PoissonCDF(k, lambda)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BinomialQuantile returns the smallest k with P(X ≤ k) ≥ q for
|
|||
|
|
// X ~ Binomial(trials, p).
|
|||
|
|
func BinomialQuantile(q, p float64, trials int) (float64, error) {
|
|||
|
|
if !(p > 0 && p < 1) {
|
|||
|
|
return 0, base.Errf("BinomialQuantile: p must lie in (0, 1), got %g", p)
|
|||
|
|
}
|
|||
|
|
return discreteQuantile("BinomialQuantile", q, func(k int) (float64, error) {
|
|||
|
|
return BinomialCDF(k, trials, p)
|
|||
|
|
})
|
|||
|
|
}
|