diff --git a/CHANGELOG.md b/CHANGELOG.md index d063bed..ecb1c0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/stats/cdf.go b/stats/cdf.go index c984e85..66b7617 100644 --- a/stats/cdf.go +++ b/stats/cdf.go @@ -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) diff --git a/stats/cdf_test.go b/stats/cdf_test.go index 0ef59bf..e440af5 100644 --- a/stats/cdf_test.go +++ b/stats/cdf_test.go @@ -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) + } +} diff --git a/stats/noncentral.go b/stats/noncentral.go index 7b599d7..6ffb311 100644 --- a/stats/noncentral.go +++ b/stats/noncentral.go @@ -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 diff --git a/stats/noncentral_test.go b/stats/noncentral_test.go index 211081d..7a7c730 100644 --- a/stats/noncentral_test.go +++ b/stats/noncentral_test.go @@ -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. diff --git a/stats/regression.go b/stats/regression.go index bc2a7d7..cb7c9ae 100644 --- a/stats/regression.go +++ b/stats/regression.go @@ -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