fix(stats): hold the Student-t tails past t-squared overflow

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-27 17:03:36 +02:00
parent 2f9358c512
commit 275de79123
6 changed files with 113 additions and 10 deletions
+5
View File
@@ -28,6 +28,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `Viterbi` refuses a sequence of probability zero under the model,
the same refusal `Forward` makes, instead of returning a meaningless
path beside a log probability of -Inf.
- The Student-t tails stay accurate past the point t squared
overflows float64: `StudentTCDF`, the regression coefficient
p-values and `NoncentralTCDF` at extreme t answer the tail the
format still holds instead of a silent zero or an error naming a
NaN.
## [1.0.0] - 2026-09-03
+11 -5
View File
@@ -269,15 +269,18 @@ 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)
// One house tail: the upper-tail helper carries the asymptotic forms
// the heavy df ≤ 2 laws keep past t²'s overflow, where the closed
// form's z = df/(df+t²) collapses to 0 and the lower tail answered a
// silent 0 for a tail the format still holds.
upper, err := studentTUpperTail(math.Abs(t), df)
if err != nil {
return 0, base.Errf("StudentTCDF: %w", err)
}
if t >= 0 {
return 1 - upper/2, nil
return 1 - upper, nil
}
return upper / 2, nil
return upper, nil
}
// PoissonCDF returns P(N ≤ k) for N ~ Poisson(lambda), through the
@@ -701,7 +704,10 @@ func studentTUpperTail(t float64, df int) (float64, error) {
// huge t keeps accurate.
return 1 / (math.Pi * t), nil
case df == 2:
return 1 / (2 * t * t), nil
// The tail 1/(2t²) divided one t at a time: the literal
// denominator overflows past √MaxFloat64, and the quotient
// would flush the still-representable tail to zero.
return 0.5 / t / t, nil
}
}
z := float64(df) / (float64(df) + t*t)
+53
View File
@@ -276,3 +276,56 @@ func TestGammaLowerLargeShape(t *testing.T) {
}
}
}
// TestStudentTCDFSquareOverflowTail pins the Student laws past the square
// overflow: a t beyond √MaxFloat64 drives z = df/(df+t²) through Inf/Inf
// to the NaN and 0 the closed form answers, while the heavy df = 1 and
// df = 2 tails are still representable there. The lower tail and the
// two-sided tail must answer their asymptotic forms, exactly as the
// one-sided upper tail already does.
func TestStudentTCDFSquareOverflowTail(t *testing.T) {
const huge = 1e155
// df = 1 (Cauchy): the one-sided tail is 1/(π·t), two-sided 2/(π·t).
one, err := StudentTCDF(-huge, 1)
if err != nil {
t.Fatalf("StudentTCDF(-1e155, 1): %v", err)
}
if want := 1 / (math.Pi * huge); math.Abs(one-want) > 1e-12*want {
t.Fatalf("StudentTCDF(-1e155, 1) = %.17g, want the Cauchy tail %.17g", one, want)
}
two, err := twoSidedT(huge, 1)
if err != nil {
t.Fatalf("twoSidedT(1e155, 1): %v", err)
}
if want := 2 / (math.Pi * huge); math.Abs(two-want) > 1e-12*want {
t.Fatalf("twoSidedT(1e155, 1) = %.17g, want the Cauchy tail %.17g", two, want)
}
// df = 2: the two-sided tail is 1 − t/√(t²+2), the one-sided half of
// it, both ≈ 1/t² here and still inside the subnormal range. The
// reference is assembled as u/(√(1+u)+1) with u = 2/t², the form
// that never squares t.
const u = 2e-310 // 2/t² at t = 1e155
two2, err := twoSidedT(huge, 2)
if err != nil {
t.Fatalf("twoSidedT(1e155, 2): %v", err)
}
if want := u / (math.Sqrt(1+u) + 1); math.Abs(two2-want) > 1e-6*want {
t.Fatalf("twoSidedT(1e155, 2) = %.17g, want the df 2 tail %.17g", two2, want)
}
one2, err := StudentTCDF(-huge, 2)
if err != nil {
t.Fatalf("StudentTCDF(-1e155, 2): %v", err)
}
if want := 0.5 * u / (math.Sqrt(1+u) + 1); math.Abs(one2-want) > 1e-6*want {
t.Fatalf("StudentTCDF(-1e155, 2) = %.17g, want the df 2 tail %.17g", one2, want)
}
// The upper half of the axis keeps answering 1, and a df whose tail
// genuinely underflows keeps answering 0: both are the honest
// roundings there.
if v, err := StudentTCDF(huge, 1); err != nil || v != 1 {
t.Fatalf("StudentTCDF(1e155, 1) = %v (%v), want 1", v, err)
}
if v, err := StudentTCDF(-huge, 4); err != nil || v != 0 {
t.Fatalf("StudentTCDF(-1e155, 4) = %v (%v), want the underflowed 0", v, err)
}
}
+8 -1
View File
@@ -304,7 +304,14 @@ func NoncentralTCDF(t float64, df int, delta float64) (float64, error) {
magnitude = -t
shift = -delta
}
x := magnitude * magnitude / (magnitude*magnitude + float64(df))
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
}
if x == 0 {
// t = 0: the value collapses to Φ(−δ) exactly.
return NormalCDF(-delta), nil
+30
View File
@@ -219,6 +219,36 @@ func TestNoncentralUnderflowSurvival(t *testing.T) {
}
}
// TestNoncentralTCDFHugeFiniteT pins the far corner of the signed axis: a
// finite t whose square overflows drives the beta argument to Inf/Inf, a
// NaN the incomplete beta refused under its own name. The CDF there is 1
// below rounding for t on the δ side and 0 above it, the same limits the
// central law answers.
func TestNoncentralTCDFHugeFiniteT(t *testing.T) {
for _, c := range []struct {
tv float64
df int
delta float64
want float64
}{
{1e200, 3, 2, 1},
{1e155, 1, 0.5, 1},
{-1e200, 5, 1, 0},
{-1e155, 2, -3, 0},
} {
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)
}
if math.IsNaN(got) || got < 0 || got > 1 {
t.Fatalf("NoncentralTCDF(%g, %d, %g) = %g, want a probability", c.tv, c.df, c.delta, got)
}
if math.Abs(got-c.want) > 1e-15 {
t.Fatalf("NoncentralTCDF(%g, %d, %g) = %.17g, want %g", c.tv, c.df, c.delta, got, c.want)
}
}
}
// 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.
+6 -4
View File
@@ -335,14 +335,16 @@ func LinearRegression(x, y *core.Array) (*LinearRegressionResult, error) {
// freedom, by the closed-form tail I_z(df/2, 1/2) with z = df/(df+t²).
// The identity is used rather than 2·(1 − T_cdf(t)): near t = 0 the
// subtraction cancels catastrophically, while the incomplete beta
// stays accurate into the far tail where p values matter most.
// stays accurate into the far tail where p values matter most. The
// upper-tail helper carries it, so the asymptotic forms that hold the
// df ≤ 2 tails past t²'s overflow serve here too: the bare closed form
// answers a silent 0 there while the true tail is still representable.
func twoSidedT(t float64, df int) (float64, error) {
z := float64(df) / (float64(df) + t*t)
p, err := BetaIncomplete(z, float64(df)/2, 0.5)
upper, err := studentTUpperTail(math.Abs(t), df)
if err != nil {
return 0, err
}
return p, nil
return 2 * upper, nil
}
// hasConstantColumn reports whether an (n, p) design holds a column of