284 lines
9.5 KiB
Go
284 lines
9.5 KiB
Go
// 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"
|
||
|
|
)
|
||
|
|
|
||
|
|
// TestLogisticRegressionRecoversCoefficients fits a generated binary
|
||
|
|
// response whose truth is known: with 2000 samples the Newton fit
|
||
|
|
// must land within a few standard errors of the generating
|
||
|
|
// coefficients, the Wald statistics must match the coefficients, and
|
||
|
|
// the fitted probabilities must increase in the direction of the
|
||
|
|
// true slope.
|
||
|
|
func TestLogisticRegressionRecoversCoefficients(t *testing.T) {
|
||
|
|
g := core.NewGenerator(7)
|
||
|
|
const n = 2000
|
||
|
|
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
|
||
|
|
pr := 1 / (1 + math.Exp(-(0.5 + 1.5*xv)))
|
||
|
|
bit := 0.0
|
||
|
|
if g.Unit() < pr {
|
||
|
|
bit = 1
|
||
|
|
}
|
||
|
|
y.RawFloats()[i] = bit
|
||
|
|
}
|
||
|
|
res, err := LogisticRegression(design, y)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LogisticRegression: %v", err)
|
||
|
|
}
|
||
|
|
if !res.Converged {
|
||
|
|
t.Fatal("the fit reported no convergence")
|
||
|
|
}
|
||
|
|
if math.Abs(res.Coefficients[0]-0.5) > 4*res.StandardErrors[0] {
|
||
|
|
t.Fatalf("intercept = %.4g (%.4g SE), outside four SEs of 0.5",
|
||
|
|
res.Coefficients[0], res.StandardErrors[0])
|
||
|
|
}
|
||
|
|
if math.Abs(res.Coefficients[1]-1.5) > 4*res.StandardErrors[1] {
|
||
|
|
t.Fatalf("slope = %.4g (%.4g SE), outside four SEs of 1.5",
|
||
|
|
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])
|
||
|
|
}
|
||
|
|
last := res.Fitted[n-1]
|
||
|
|
first := res.Fitted[0]
|
||
|
|
if !(last > first) {
|
||
|
|
t.Fatalf("fitted probabilities not increasing: %g then %g", first, last)
|
||
|
|
}
|
||
|
|
// The maximised likelihood must beat the null model's.
|
||
|
|
nullLike := float64(n) * math.Log(0.5)
|
||
|
|
if res.LogLikelihood <= nullLike {
|
||
|
|
t.Fatalf("log likelihood %.4g does not beat the null %.4g", res.LogLikelihood, nullLike)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLogisticRegressionRefusals checks the response and design
|
||
|
|
// guards, including the separable-data refusal.
|
||
|
|
func TestLogisticRegressionRefusals(t *testing.T) {
|
||
|
|
design := core.New(core.Float, 4, 2)
|
||
|
|
y := core.New(core.Float, 4)
|
||
|
|
if _, err := LogisticRegression(core.New(core.Float, 4), y); err == nil {
|
||
|
|
t.Fatal("rank-1 design accepted")
|
||
|
|
}
|
||
|
|
bad := core.New(core.Float, 4)
|
||
|
|
bad.RawFloats()[2] = 0.5
|
||
|
|
if _, err := LogisticRegression(design, bad); err == nil {
|
||
|
|
t.Fatal("non-binary response accepted")
|
||
|
|
}
|
||
|
|
// Perfect separation: y = 1 exactly when x > 0 has no finite
|
||
|
|
// optimum, and the run must say so instead of diverging.
|
||
|
|
xsep := core.New(core.Float, 8, 2)
|
||
|
|
sep := core.New(core.Float, 8)
|
||
|
|
for i := range 8 {
|
||
|
|
xsep.RawFloats()[i*2] = 1
|
||
|
|
xsep.RawFloats()[i*2+1] = float64(i) - 3.5
|
||
|
|
if i >= 4 {
|
||
|
|
sep.RawFloats()[i] = 1
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if _, err := LogisticRegression(xsep, sep); err == nil {
|
||
|
|
t.Fatal("separable data accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestMultivariateNormalDensity checks the density against the
|
||
|
|
// two-dimensional formula with a diagonal covariance, where the
|
||
|
|
// answer is a product of one-dimensional normals.
|
||
|
|
func TestMultivariateNormalDensity(t *testing.T) {
|
||
|
|
mean := smallVector(t, []float64{1, -2})
|
||
|
|
cov, err := core.FromFloats([]float64{4, 0, 0, 9}, 2, 2)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("cov: %v", err)
|
||
|
|
}
|
||
|
|
x := smallVector(t, []float64{3, 1})
|
||
|
|
got, err := MultivariateNormalLogDensity(mean, cov, x)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MultivariateNormalLogDensity: %v", err)
|
||
|
|
}
|
||
|
|
dx := float64(3-1) / 2
|
||
|
|
dy := float64(1-(-2)) / 3
|
||
|
|
want := math.Log(1/(2*math.Pi*6)) - 0.5*dx*dx - 0.5*dy*dy
|
||
|
|
if math.Abs(got-want) > 1e-12 {
|
||
|
|
t.Fatalf("log density = %.14g, want %.14g", got, want)
|
||
|
|
}
|
||
|
|
atMean, err := MultivariateNormalLogDensity(mean, cov, mean)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("density at the mean: %v", err)
|
||
|
|
}
|
||
|
|
if math.Abs(atMean-math.Log(1/(2*math.Pi*6))) > 1e-12 {
|
||
|
|
t.Fatalf("density at the mean = %.14g, want the normaliser", atMean)
|
||
|
|
}
|
||
|
|
if _, err := MultivariateNormalLogDensity(mean, smallVector(t, []float64{1, 2, 3}), x); err == nil {
|
||
|
|
t.Fatal("mismatched covariance accepted")
|
||
|
|
}
|
||
|
|
// A negative pivot must be refused by name.
|
||
|
|
bad, _ := core.FromFloats([]float64{1, 0, 0, -4}, 2, 2)
|
||
|
|
if _, err := MultivariateNormalLogDensity(mean, bad, x); err == nil {
|
||
|
|
t.Fatal("indefinite covariance accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestMultivariateNormalDraws checks the sampler's moments: with
|
||
|
|
// 60000 draws the sample mean and covariance must sit close to the
|
||
|
|
// parameters, well inside the Monte Carlo error of the moment.
|
||
|
|
func TestMultivariateNormalDraws(t *testing.T) {
|
||
|
|
g := core.NewGenerator(11)
|
||
|
|
mean := smallVector(t, []float64{2, -1})
|
||
|
|
cov, err := core.FromFloats([]float64{1, 0.5, 0.5, 4}, 2, 2)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("cov: %v", err)
|
||
|
|
}
|
||
|
|
const n = 60000
|
||
|
|
draws, err := MultivariateNormalDraws(g, n, mean, cov)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MultivariateNormalDraws: %v", err)
|
||
|
|
}
|
||
|
|
if draws.Shape()[0] != n || draws.Shape()[1] != 2 {
|
||
|
|
t.Fatalf("shape %v, want [%d 2]", draws.Shape(), n)
|
||
|
|
}
|
||
|
|
m1, m2, c, v1, v2 := 0.0, 0.0, 0.0, 0.0, 0.0
|
||
|
|
for i := range n {
|
||
|
|
x := draws.FloatAt(i * 2)
|
||
|
|
y := draws.FloatAt(i*2 + 1)
|
||
|
|
m1 += x
|
||
|
|
m2 += y
|
||
|
|
v1 += x * x
|
||
|
|
v2 += y * y
|
||
|
|
c += x * y
|
||
|
|
}
|
||
|
|
m1 /= n
|
||
|
|
m2 /= n
|
||
|
|
if math.Abs(m1-2) > 0.03 || math.Abs(m2+1) > 0.05 {
|
||
|
|
t.Fatalf("means = %.4f, %.4f, want 2, -1", m1, m2)
|
||
|
|
}
|
||
|
|
if v1/n-m1*m1 > 1.06 || v2/n-m2*m2 > 4.25 {
|
||
|
|
t.Fatalf("variances = %.4f, %.4f, want about 1 and 4", v1/n-m1*m1, v2/n-m2*m2)
|
||
|
|
}
|
||
|
|
covHat := c/n - m1*m2
|
||
|
|
if math.Abs(covHat-0.5) > 0.05 {
|
||
|
|
t.Fatalf("covariance = %.4f, want 0.5", covHat)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestKernelDensity checks the estimate on a standard normal sample:
|
||
|
|
// it must integrate to one over a wide grid, peak near the true mode
|
||
|
|
// and stay non-negative everywhere.
|
||
|
|
func TestKernelDensity(t *testing.T) {
|
||
|
|
g := core.NewGenerator(23)
|
||
|
|
const n = 3000
|
||
|
|
sampleVals := make([]float64, n)
|
||
|
|
for i := range n {
|
||
|
|
sampleVals[i] = g.NormalUnit()
|
||
|
|
}
|
||
|
|
sample := smallVector(t, sampleVals)
|
||
|
|
const lo, hi = -5.0, 5.0
|
||
|
|
const grid = 400
|
||
|
|
pointVals := make([]float64, grid)
|
||
|
|
for i := range grid {
|
||
|
|
pointVals[i] = lo + (hi-lo)*float64(i)/float64(grid-1)
|
||
|
|
}
|
||
|
|
points := smallVector(t, pointVals)
|
||
|
|
density, err := KernelDensity(sample, 0, points)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("KernelDensity: %v", err)
|
||
|
|
}
|
||
|
|
vals := density.RawFloats()[:density.Len()]
|
||
|
|
total := 0.0
|
||
|
|
for i, v := range vals {
|
||
|
|
if v < 0 {
|
||
|
|
t.Fatalf("negative density at %g", pointVals[i])
|
||
|
|
}
|
||
|
|
if i > 0 {
|
||
|
|
total += 0.5 * (v + vals[i-1]) * ((hi - lo) / (grid - 1))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if math.Abs(total-1) > 0.01 {
|
||
|
|
t.Fatalf("the estimate integrates to %.4f, want 1", total)
|
||
|
|
}
|
||
|
|
peak, peakAt := 0.0, 0.0
|
||
|
|
for i, v := range vals {
|
||
|
|
if v > peak {
|
||
|
|
peak, peakAt = v, pointVals[i]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if math.Abs(peakAt) > 0.25 {
|
||
|
|
t.Fatalf("the estimate peaks at %.3f, want the true mode near 0", peakAt)
|
||
|
|
}
|
||
|
|
if peak < 0.3 || peak > 0.5 {
|
||
|
|
t.Fatalf("peak height %.4f, want the normal's 0.399 within a KDE's honesty", peak)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLogisticRegressionRefusesNonFiniteDesign pins the finite-input
|
||
|
|
// gate: a NaN coefficient in the design used to flow through the
|
||
|
|
// sigmoid and the Newton step into a fit that reported convergence on
|
||
|
|
// an all-NaN result.
|
||
|
|
func TestLogisticRegressionRefusesNonFiniteDesign(t *testing.T) {
|
||
|
|
design := core.New(core.Float, 4, 2)
|
||
|
|
for i := range 8 {
|
||
|
|
design.RawFloats()[i] = float64(i%4) + float64(i/4)
|
||
|
|
}
|
||
|
|
design.RawFloats()[5] = math.NaN()
|
||
|
|
y := core.New(core.Float, 4)
|
||
|
|
y.RawFloats()[0], y.RawFloats()[1] = 0, 1
|
||
|
|
y.RawFloats()[2], y.RawFloats()[3] = 1, 0
|
||
|
|
if _, err := LogisticRegression(design, y); err == nil || !strings.Contains(err.Error(), "non-finite") {
|
||
|
|
t.Fatalf("LogisticRegression with a NaN design: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestKernelDensityViewReadsVisibleElements pins the dense payload
|
||
|
|
// bound on both sweeps: a rebased view shares its parent's payload,
|
||
|
|
// which runs past the view's own count, so the sample and the points
|
||
|
|
// are cut to the elements a caller can see. A non-finite value in the
|
||
|
|
// invisible tail must neither poison the estimate nor refuse the call,
|
||
|
|
// and the density must be the one the standalone sample gives, bit for
|
||
|
|
// bit.
|
||
|
|
func TestKernelDensityViewReadsVisibleElements(t *testing.T) {
|
||
|
|
visible := []float64{0.5, 1.5, 2.5, 3.5}
|
||
|
|
pointVals := []float64{0, 1, 2, 3, 4}
|
||
|
|
dense := mustFromFloats(t, append(append([]float64(nil), visible...), math.NaN(), math.Inf(1)), 6)
|
||
|
|
view, err := core.Slice(dense, 0, 0, len(visible))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("Slice: %v", err)
|
||
|
|
}
|
||
|
|
pointParent := mustFromFloats(t, append(append([]float64(nil), pointVals...), math.NaN(), math.Inf(-1)), len(pointVals)+2)
|
||
|
|
pointView, err := core.Slice(pointParent, 0, 0, len(pointVals))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("Slice: %v", err)
|
||
|
|
}
|
||
|
|
got, err := KernelDensity(view, 0.5, pointView)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("KernelDensity over views with a non-finite tail: %v", err)
|
||
|
|
}
|
||
|
|
want, err := KernelDensity(mustFloats(t, visible, len(visible)), 0.5, mustFloats(t, pointVals, len(pointVals)))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("KernelDensity over the visible elements: %v", err)
|
||
|
|
}
|
||
|
|
if got.Len() != want.Len() {
|
||
|
|
t.Fatalf("the estimate carries %d values, want %d", got.Len(), want.Len())
|
||
|
|
}
|
||
|
|
for i := range want.Len() {
|
||
|
|
if gb, wb := math.Float64bits(got.FloatAt(i)), math.Float64bits(want.FloatAt(i)); gb != wb {
|
||
|
|
t.Fatalf("density[%d] = %v (%#x) over the view, %v (%#x) over the visible elements",
|
||
|
|
i, got.FloatAt(i), gb, want.FloatAt(i), wb)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// The sample's own view is the whole parent's, tail included: the
|
||
|
|
// guard must still refuse it by name.
|
||
|
|
if _, err := KernelDensity(dense, 0.5, pointView); err == nil || !strings.Contains(err.Error(), "non-finite") {
|
||
|
|
t.Fatalf("KernelDensity over the whole parent: %v, want the non-finite refusal", err)
|
||
|
|
}
|
||
|
|
}
|