Files
tensor/stats/poissonregression_test.go
T

294 lines
9.8 KiB
Go
Raw Normal View History

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 (
"math"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// TestPoissonRegressionRecoversCoefficients fits a generated count
// response whose truth is known: with 1500 samples the Newton fit must
// land within a few standard errors of the generating coefficients,
// the Wald statistics must flag the slope, and the fitted means must
// increase in the direction of the true slope.
func TestPoissonRegressionRecoversCoefficients(t *testing.T) {
g := core.NewGenerator(7)
const n = 1500
design := core.New(core.Float, n, 2)
y := core.New(core.Float, n)
for i := range n {
xv := -2 + 4*g.Unit()
design.RawFloats()[i*2] = 1
design.RawFloats()[i*2+1] = xv
counts, err := PoissonDraws(g, 1, math.Exp(0.3+0.9*xv))
if err != nil {
t.Fatalf("PoissonDraws: %v", err)
}
y.RawFloats()[i] = counts.FloatAt(0)
}
res, err := PoissonRegression(design, y)
if err != nil {
t.Fatalf("PoissonRegression: %v", err)
}
if !res.Converged {
t.Fatal("the fit reported no convergence")
}
if math.Abs(res.Coefficients[0]-0.3) > 4*res.StandardErrors[0] {
t.Fatalf("intercept = %.4g (%.4g SE), outside four SEs of 0.3",
res.Coefficients[0], res.StandardErrors[0])
}
if math.Abs(res.Coefficients[1]-0.9) > 4*res.StandardErrors[1] {
t.Fatalf("slope = %.4g (%.4g SE), outside four SEs of 0.9",
res.Coefficients[1], res.StandardErrors[1])
}
if res.ZStatistics[1] <= 3 {
t.Fatalf("slope z = %.4g, want a clearly non-zero effect", res.ZStatistics[1])
}
if !(res.Fitted[n-1] > res.Fitted[0]) {
t.Fatalf("fitted means not increasing: %g then %g", res.Fitted[0], res.Fitted[n-1])
}
}
// TestPoissonRegressionInterceptOnlyClosedForm pins the one case with
// a closed-form maximum likelihood estimate: with an intercept-only
// design the estimate is log of the mean count and its standard error
// is 1/sqrt(n·mean).
func TestPoissonRegressionInterceptOnlyClosedForm(t *testing.T) {
const n = 40
design := core.New(core.Float, n, 1)
y := core.New(core.Float, n)
total := 0.0
for i := range n {
design.RawFloats()[i] = 1
y.RawFloats()[i] = float64(i % 7)
total += float64(i % 7)
}
meanY := total / n
res, err := PoissonRegression(design, y)
if err != nil {
t.Fatalf("PoissonRegression: %v", err)
}
if math.Abs(res.Coefficients[0]-math.Log(meanY)) > 1e-9 {
t.Fatalf("intercept = %.15g, want log(%.15g) = %.15g",
res.Coefficients[0], meanY, math.Log(meanY))
}
wantSE := 1 / math.Sqrt(n*meanY)
if math.Abs(res.StandardErrors[0]-wantSE) > 1e-9*wantSE {
t.Fatalf("SE = %.15g, want %.15g", res.StandardErrors[0], wantSE)
}
for i := range n {
if math.Abs(res.Fitted[i]-meanY) > 1e-9 {
t.Fatalf("fitted[%d] = %.15g, want %.15g", i, res.Fitted[i], meanY)
}
}
// The score must vanish at the optimum.
score := 0.0
for i := range n {
score += res.Fitted[i] - y.FloatAt(i)
}
if math.Abs(score) > 1e-6 {
t.Fatalf("score at the optimum = %.3g, want 0", score)
}
}
// TestPoissonRegressionStandardErrorsClosedForm checks the Wald
// inference against a closed-form 2x2 inverse of the Fisher
// information built from the fitted means: no solve, only the
// adjugate formula.
func TestPoissonRegressionStandardErrorsClosedForm(t *testing.T) {
// Near-deterministic counts: large means make the rounding to
// integers a relative perturbation under 1e-3, so the fit must
// recover the generating coefficients tightly.
const n = 25
truth := [2]float64{0.2, 0.5}
design := core.New(core.Float, n, 2)
y := core.New(core.Float, n)
for i := range n {
xv := float64(i)
design.RawFloats()[i*2] = 1
design.RawFloats()[i*2+1] = xv
y.RawFloats()[i] = math.Round(math.Exp(truth[0] + truth[1]*xv))
}
res, err := PoissonRegression(design, y)
if err != nil {
t.Fatalf("PoissonRegression: %v", err)
}
for j := range 2 {
if math.Abs(res.Coefficients[j]-truth[j]) > 4e-3 {
t.Fatalf("coefficient %d = %.6g, want %.6g within rounding noise",
j, res.Coefficients[j], truth[j])
}
}
// Fisher information from the fitted means, inverted by the
// adjugate.
a, b, c := 0.0, 0.0, 0.0
for i := range n {
x1 := design.FloatAt(i * 2)
x2 := design.FloatAt(i*2 + 1)
mu := res.Fitted[i]
a += x1 * x1 * mu
b += x1 * x2 * mu
c += x2 * x2 * mu
}
det := a*c - b*b
wantSE0 := math.Sqrt(c / det)
wantSE1 := math.Sqrt(a / det)
if math.Abs(res.StandardErrors[0]-wantSE0) > 1e-9*wantSE0 {
t.Fatalf("SE0 = %.15g, want %.15g", res.StandardErrors[0], wantSE0)
}
if math.Abs(res.StandardErrors[1]-wantSE1) > 1e-9*wantSE1 {
t.Fatalf("SE1 = %.15g, want %.15g", res.StandardErrors[1], wantSE1)
}
z := res.ZStatistics[0]
// The p-value reference is computed independently of the tail
// formula the fit uses: Simpson quadrature of the Gaussian density,
// every summand positive, sharing nothing with NormalCDF or
// math.Erfc. The intercept's z lands far past the z ≈ 8.3 where the
// algebraic form 2·(1−Φ(z)) cancels to an exact zero, so this pin
// fails against the cancelled form, which answers 0 here.
wantP := gaussTailReference(math.Abs(z))
if math.Abs(res.PValues[0]-wantP) > 1e-6*wantP {
t.Fatalf("p-value = %.15g, want %.15g", res.PValues[0], wantP)
}
// The slope's z is in the hundreds: its tail underflows the float64
// range entirely, and an exact zero is the correctly rounded answer
// both the fit and the reference produce.
if res.PValues[1] != 0 {
t.Fatalf("p-value of the slope = %.15g, want the underflowed 0", res.PValues[1])
}
}
// TestPoissonRegressionRefusals checks the response and design
// validation and the honest failure of a singular Fisher information
// from a duplicated column.
func TestPoissonRegressionRefusals(t *testing.T) {
y4 := mustFromFloats(t, []float64{1, 0, 1, 0}, 4)
if _, err := PoissonRegression(core.New(core.Float, 4), y4); err == nil {
t.Fatal("a rank 1 design was accepted")
}
design := core.New(core.Float, 4, 2)
for i := range 4 {
design.RawFloats()[i*2] = 1
design.RawFloats()[i*2+1] = float64(i)
}
rank2 := core.New(core.Float, 2, 2)
if _, err := PoissonRegression(design, rank2); err == nil {
t.Fatal("a rank 2 response was accepted")
}
if _, err := PoissonRegression(design, mustFromFloats(t, []float64{1, 0, 1}, 3)); err == nil {
t.Fatal("a length mismatch was accepted")
}
if _, err := PoissonRegression(design, mustFromFloats(t, []float64{1, 0, 1, -2}, 4)); err == nil {
t.Fatal("a negative count was accepted")
}
if _, err := PoissonRegression(design, mustFromFloats(t, []float64{1, 0, 1, 0.5}, 4)); err == nil {
t.Fatal("a fractional count was accepted")
}
if _, err := PoissonRegression(design, mustFromFloats(t, []float64{1, 0, math.NaN(), 0}, 4)); err == nil {
t.Fatal("a NaN count was accepted")
}
if _, err := PoissonRegression(design, mustFromFloats(t, []float64{1, 0, math.Inf(1), 0}, 4)); err == nil {
t.Fatal("an infinite count was accepted")
}
bad := mustFromFloats(t, []float64{1, math.NaN(), 0, 1, 2, 0, 1, 2}, 4, 2)
if _, err := PoissonRegression(bad, y4); err == nil || !strings.Contains(err.Error(), "non-finite") {
t.Fatalf("PoissonRegression with a NaN design: %v", err)
}
// n <= p has no unique fit.
square := core.New(core.Float, 2, 2)
square.RawFloats()[0] = 1
square.RawFloats()[3] = 1
if _, err := PoissonRegression(square, mustFromFloats(t, []float64{1, 2}, 2)); err == nil {
t.Fatal("n <= p was accepted")
}
// A duplicated column makes the Fisher information singular at the
// start.
dup := core.New(core.Float, 4, 2)
for i := range 4 {
dup.RawFloats()[i*2] = float64(i)
dup.RawFloats()[i*2+1] = float64(i)
}
if _, err := PoissonRegression(dup, y4); err == nil || !strings.Contains(err.Error(), "singular") {
t.Fatalf("a duplicated column: %v", err)
}
}
// TestPoissonRegressionIsDeterministic runs the same fit twice and
// requires identical coefficients bit for bit, as every Tensor entry
// point promises.
func TestPoissonRegressionIsDeterministic(t *testing.T) {
const n = 300
build := func(seed int64) (*core.Array, *core.Array) {
g := core.NewGenerator(seed)
design := core.New(core.Float, n, 2)
y := core.New(core.Float, n)
for i := range n {
xv := -1 + 2*g.Unit()
design.RawFloats()[i*2] = 1
design.RawFloats()[i*2+1] = xv
counts, err := PoissonDraws(g, 1, math.Exp(0.4+0.6*xv))
if err != nil {
t.Fatalf("PoissonDraws: %v", err)
}
y.RawFloats()[i] = counts.FloatAt(0)
}
return design, y
}
d1, y1 := build(11)
d2, y2 := build(11)
r1, err := PoissonRegression(d1, y1)
if err != nil {
t.Fatalf("first fit: %v", err)
}
r2, err := PoissonRegression(d2, y2)
if err != nil {
t.Fatalf("second fit: %v", err)
}
for j := range r1.Coefficients {
if r1.Coefficients[j] != r2.Coefficients[j] {
t.Fatalf("coefficient %d differs: %.17g vs %.17g",
j, r1.Coefficients[j], r2.Coefficients[j])
}
}
if r1.LogLikelihood != r2.LogLikelihood {
t.Fatalf("log likelihood differs: %.17g vs %.17g",
r1.LogLikelihood, r2.LogLikelihood)
}
}
// TestPoissonRegressionFloat32Design runs the fit over a float32
// design, the widened path the raw-slice fast path must agree with.
func TestPoissonRegressionFloat32Design(t *testing.T) {
const n = 120
f64 := core.New(core.Float, n, 2)
f32 := core.New(core.Float32, n, 2)
y := core.New(core.Float, n)
for i := range n {
xv := -2 + 4*float64(i)/float64(n-1)
f64.RawFloats()[i*2] = 1
f64.RawFloats()[i*2+1] = xv
f32.SetFloatAt(i*2, 1)
f32.SetFloatAt(i*2+1, xv)
y.RawFloats()[i] = math.Round(math.Exp(0.2 + 0.3*xv))
}
ref, err := PoissonRegression(f64, y)
if err != nil {
t.Fatalf("float64 fit: %v", err)
}
got, err := PoissonRegression(f32, y)
if err != nil {
t.Fatalf("float32 fit: %v", err)
}
for j := range ref.Coefficients {
if math.Abs(got.Coefficients[j]-ref.Coefficients[j]) > 1e-6 {
t.Fatalf("coefficient %d: float32 %.12g vs float64 %.12g",
j, got.Coefficients[j], ref.Coefficients[j])
}
}
}