feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package stats
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// huberFixture builds the seeded contaminated fixture the Huber pins
|
||||
// run on: sixty observations of a clean linear model, then ten percent
|
||||
// of the responses pushed thirty units off the trend. It returns the
|
||||
// design with the constant column first, the clean response and the
|
||||
// contaminated response.
|
||||
func huberFixture(t *testing.T) (*core.Array, *core.Array, *core.Array) {
|
||||
t.Helper()
|
||||
const n = 60
|
||||
g := core.NewGenerator(11)
|
||||
designVals := make([]float64, 0, 2*n)
|
||||
clean := make([]float64, n)
|
||||
for i := range n {
|
||||
x := g.NormalUnit()
|
||||
clean[i] = 2 + 3*x + 0.5*g.NormalUnit()
|
||||
designVals = append(designVals, 1, x)
|
||||
}
|
||||
design := mustFromFloats(t, designVals, n, 2)
|
||||
contaminated := append([]float64(nil), clean...)
|
||||
for i := 0; i < n; i += 10 {
|
||||
contaminated[i] += 100
|
||||
}
|
||||
return design, mustFromFloats(t, clean, n), mustFromFloats(t, contaminated, n)
|
||||
}
|
||||
|
||||
// TestHuberResistsOutliers is the pin that gives the estimator its
|
||||
// reason to exist: on the contaminated fixture the Huber fit lands
|
||||
// beside the clean-data ordinary least squares answer, while plain
|
||||
// least squares on the same contaminated data is dragged visibly off
|
||||
// it. Both margins are measured, not asserted as anecdotes.
|
||||
func TestHuberResistsOutliers(t *testing.T) {
|
||||
design, clean, contaminated := huberFixture(t)
|
||||
cleanOLS, err := LinearRegression(design, clean)
|
||||
if err != nil {
|
||||
t.Fatalf("LinearRegression on the clean data: %v", err)
|
||||
}
|
||||
if math.Abs(cleanOLS.Coefficients[0]-2) > 0.2 || math.Abs(cleanOLS.Coefficients[1]-3) > 0.2 {
|
||||
t.Fatalf("the clean-data OLS is off the truth: (%.4f, %.4f)", cleanOLS.Coefficients[0], cleanOLS.Coefficients[1])
|
||||
}
|
||||
olsCont, err := LinearRegression(design, contaminated)
|
||||
if err != nil {
|
||||
t.Fatalf("LinearRegression on the contaminated data: %v", err)
|
||||
}
|
||||
huber, err := HuberRegression(design, contaminated)
|
||||
if err != nil {
|
||||
t.Fatalf("HuberRegression: %v", err)
|
||||
}
|
||||
if !huber.Converged {
|
||||
t.Fatalf("the Huber fit did not converge")
|
||||
}
|
||||
dragged := math.Abs(olsCont.Coefficients[1] - cleanOLS.Coefficients[1])
|
||||
resisted := math.Abs(huber.Coefficients[1] - cleanOLS.Coefficients[1])
|
||||
t.Logf("slope: clean OLS %.4f, contaminated OLS %.4f (drag %.4f), contaminated Huber %.4f (drag %.4f)",
|
||||
cleanOLS.Coefficients[1], olsCont.Coefficients[1], dragged, huber.Coefficients[1], resisted)
|
||||
if dragged < 0.3 {
|
||||
t.Fatalf("the contamination failed to drag OLS in the first place: drag %.4f", dragged)
|
||||
}
|
||||
if resisted > 0.15 {
|
||||
t.Fatalf("the Huber slope sits %.4f from the clean-data answer, want at most 0.15", resisted)
|
||||
}
|
||||
if d := math.Abs(huber.Coefficients[0] - cleanOLS.Coefficients[0]); d > 0.15 {
|
||||
t.Fatalf("the Huber intercept sits %.4f from the clean-data answer, want at most 0.15", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHuberWeightsBandTaper pins the weight function itself: exactly
|
||||
// one strictly inside the band and on its boundary, the band over the
|
||||
// magnitude outside it, and no weight anywhere else on the number
|
||||
// line. The fixture's contaminated observations must then be the ones
|
||||
// the fit tapered hardest.
|
||||
func TestHuberWeightsBandTaper(t *testing.T) {
|
||||
// The function, on exact values.
|
||||
if huberWeight(0.4, 1) != 1 {
|
||||
t.Fatalf("a residual inside the band does not carry weight one")
|
||||
}
|
||||
if huberWeight(1, 1) != 1 {
|
||||
t.Fatalf("a residual on the band boundary does not carry weight one")
|
||||
}
|
||||
if huberWeight(-1, 1) != 1 {
|
||||
t.Fatalf("the band is not symmetric: a negative boundary residual tapered")
|
||||
}
|
||||
if w := huberWeight(2, 1); w != 0.5 {
|
||||
t.Fatalf("a residual at twice the band carries %g, want 0.5", w)
|
||||
}
|
||||
if w := huberWeight(-3, 1.5); w != 0.5 {
|
||||
t.Fatalf("a negative residual at twice the band carries %g, want 0.5", w)
|
||||
}
|
||||
if huberWeight(0, 0) != 1 {
|
||||
t.Fatalf("a collapsed band must keep a zero residual at weight one")
|
||||
}
|
||||
if huberWeight(1, 0) != 0 {
|
||||
t.Fatalf("a collapsed band must give a nonzero residual zero weight")
|
||||
}
|
||||
// The taper decreases strictly beyond the band.
|
||||
previous := 1.0
|
||||
for magnitude := 1.5; magnitude < 20; magnitude += 0.5 {
|
||||
w := huberWeight(magnitude, 1)
|
||||
if w >= previous {
|
||||
t.Fatalf("the taper rose at magnitude %.1f: %g after %g", magnitude, w, previous)
|
||||
}
|
||||
previous = w
|
||||
}
|
||||
// The fit's weights: the six contaminated observations are the six
|
||||
// lightest.
|
||||
design, _, contaminated := huberFixture(t)
|
||||
huber, err := HuberRegression(design, contaminated)
|
||||
if err != nil {
|
||||
t.Fatalf("HuberRegression: %v", err)
|
||||
}
|
||||
weights := append([]float64(nil), huber.Weights...)
|
||||
sorted := append([]float64(nil), weights...)
|
||||
slices.Sort(sorted)
|
||||
lightest := map[float64]bool{}
|
||||
for _, w := range sorted[:6] {
|
||||
lightest[w] = true
|
||||
}
|
||||
for i := 0; i < 60; i += 10 {
|
||||
if !lightest[huber.Weights[i]] {
|
||||
t.Fatalf("contaminated observation %d carries weight %.4f, not among the six lightest", i, huber.Weights[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHuberExactFit walks the collapsed-scale corner: a constant
|
||||
// response is reproduced exactly by the unpenalised fit, every
|
||||
// residual is zero, the robust scale is zero, and the fit reports the
|
||||
// exactness instead of dividing by it.
|
||||
func TestHuberExactFit(t *testing.T) {
|
||||
design := mustFromFloats(t, []float64{1, 0, 1, 1, 1, 2, 1, 3, 1, 4}, 5, 2)
|
||||
y := mustFromFloats(t, []float64{2.5, 2.5, 2.5, 2.5, 2.5}, 5)
|
||||
res, err := HuberRegression(design, y)
|
||||
if err != nil {
|
||||
t.Fatalf("HuberRegression: %v", err)
|
||||
}
|
||||
if !res.Converged {
|
||||
t.Fatalf("the exact fit did not report convergence")
|
||||
}
|
||||
if res.Scale != 0 {
|
||||
t.Fatalf("the exact fit reports scale %g, want 0", res.Scale)
|
||||
}
|
||||
if math.Abs(res.Coefficients[0]-2.5) > 1e-9 || math.Abs(res.Coefficients[1]) > 1e-9 {
|
||||
t.Fatalf("the exact fit moved: (%.12g, %.12g), want (2.5, 0)", res.Coefficients[0], res.Coefficients[1])
|
||||
}
|
||||
for i := range 5 {
|
||||
if res.Weights[i] != 1 {
|
||||
t.Fatalf("an exact fit tapered observation %d to weight %g", i, res.Weights[i])
|
||||
}
|
||||
if math.Abs(res.Residuals[i]) > 1e-12 {
|
||||
t.Fatalf("the exact fit left residual %g at observation %d", res.Residuals[i], i)
|
||||
}
|
||||
if res.StandardErrors[0] != 0 {
|
||||
t.Fatalf("the exact fit reports standard error %g, want 0", res.StandardErrors[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheilSenExactLine pins the estimator on an outlier-free line:
|
||||
// every pairwise slope of y = 2 + 3x is exactly 3 in floating point,
|
||||
// so their median is exactly 3 and the intercept exactly 2, bit for
|
||||
// bit.
|
||||
func TestTheilSenExactLine(t *testing.T) {
|
||||
const n = 10
|
||||
xs := make([]float64, n)
|
||||
ys := make([]float64, n)
|
||||
for i := range n {
|
||||
xs[i] = float64(i)
|
||||
ys[i] = 2 + 3*float64(i)
|
||||
}
|
||||
x := mustFromFloats(t, xs, n)
|
||||
y := mustFromFloats(t, ys, n)
|
||||
intercept, slope, err := TheilSenRegression(x, y)
|
||||
if err != nil {
|
||||
t.Fatalf("TheilSenRegression: %v", err)
|
||||
}
|
||||
if slope != 3 {
|
||||
t.Fatalf("slope = %.17g, want exactly 3", slope)
|
||||
}
|
||||
if intercept != 2 {
|
||||
t.Fatalf("intercept = %.17g, want exactly 2", intercept)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheilSenSurvivesBrokenPoint breaks one observation of an exact
|
||||
// line by a thousand units: the least squares slope is destroyed by
|
||||
// the leverage, while the Theil-Sen slope and intercept stay exactly
|
||||
// on the line, because the broken point's slopes sit outside the
|
||||
// median window.
|
||||
func TestTheilSenSurvivesBrokenPoint(t *testing.T) {
|
||||
const n, broken = 25, 7
|
||||
xs := make([]float64, n)
|
||||
ys := make([]float64, n)
|
||||
for i := range n {
|
||||
xs[i] = float64(i)
|
||||
ys[i] = 1 + 0.5*float64(i)
|
||||
}
|
||||
ys[broken] += 1000
|
||||
x := mustFromFloats(t, xs, n)
|
||||
y := mustFromFloats(t, ys, n)
|
||||
design := mustFromFloats(t, []float64{1, 0, 1, 1, 1, 2, 1, 3, 1, 4, 1, 5, 1, 6, 1, 7, 1, 8, 1, 9, 1, 10, 1, 11, 1, 12, 1, 13, 1, 14, 1, 15, 1, 16, 1, 17, 1, 18, 1, 19, 1, 20, 1, 21, 1, 22, 1, 23, 1, 24}, n, 2)
|
||||
ols, err := LinearRegression(design, y)
|
||||
if err != nil {
|
||||
t.Fatalf("LinearRegression: %v", err)
|
||||
}
|
||||
dragged := math.Abs(ols.Coefficients[1] - 0.5)
|
||||
t.Logf("one broken point drags the OLS slope to %.4f; Theil-Sen holds it", ols.Coefficients[1])
|
||||
if dragged < 3 {
|
||||
t.Fatalf("the broken point failed to destroy OLS in the first place: drag %.4f", dragged)
|
||||
}
|
||||
intercept, slope, err := TheilSenRegression(x, y)
|
||||
if err != nil {
|
||||
t.Fatalf("TheilSenRegression: %v", err)
|
||||
}
|
||||
if slope != 0.5 {
|
||||
t.Fatalf("slope = %.17g under one broken point, want exactly 0.5", slope)
|
||||
}
|
||||
if intercept != 1 {
|
||||
t.Fatalf("intercept = %.17g under one broken point, want exactly 1", intercept)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheilSenValidation refuses the inputs the estimator cannot
|
||||
// answer exactly: wrong shapes, non-finite samples, degenerate
|
||||
// predictors and samples past the exactness contract.
|
||||
func TestTheilSenValidation(t *testing.T) {
|
||||
x := mustFromFloats(t, []float64{0, 1, 2, 3}, 4)
|
||||
y := mustFromFloats(t, []float64{0, 1, 2, 3}, 4)
|
||||
if _, _, err := TheilSenRegression(mustFromFloats(t, []float64{1, 2, 3, 4, 5, 6, 7, 8}, 4, 2), y); err == nil || !strings.Contains(err.Error(), "the predictor must be rank 1") {
|
||||
t.Fatalf("a rank 2 predictor: got %v, want the rank refusal", err)
|
||||
}
|
||||
if _, _, err := TheilSenRegression(x, mustFromFloats(t, []float64{1, 2, 3, 4, 5, 6}, 3, 2)); err == nil || !strings.Contains(err.Error(), "the response must be rank 1") {
|
||||
t.Fatalf("a rank 2 response: got %v, want the rank refusal", err)
|
||||
}
|
||||
if _, _, err := TheilSenRegression(x, mustFromFloats(t, []float64{1, 2, 3}, 3)); err == nil || !strings.Contains(err.Error(), "samples but the response") {
|
||||
t.Fatalf("a length mismatch: got %v, want the length refusal", err)
|
||||
}
|
||||
if _, _, err := TheilSenRegression(x, core.New(core.Complex, 4)); err == nil || !strings.Contains(err.Error(), "complex") {
|
||||
t.Fatalf("a complex response: got %v, want the complex refusal", err)
|
||||
}
|
||||
// Two agreeing observations reach the three-observation floor the
|
||||
// simple model needs for a median of pairwise slopes.
|
||||
if _, _, err := TheilSenRegression(mustFromFloats(t, []float64{0, 1}, 2), mustFromFloats(t, []float64{0, 1}, 2)); err == nil || !strings.Contains(err.Error(), "at least three observations") {
|
||||
t.Fatalf("two observations: got %v, want the three-observation floor", err)
|
||||
}
|
||||
if _, _, err := TheilSenRegression(mustFromFloats(t, []float64{2, 2, 2, 2}, 4), y); err == nil || !strings.Contains(err.Error(), "does not vary") {
|
||||
t.Fatalf("a constant predictor: got %v, want the no-slope refusal", err)
|
||||
}
|
||||
if _, _, err := TheilSenRegression(mustFromFloats(t, []float64{0, 1, math.NaN(), 3}, 4), y); err == nil || !strings.Contains(err.Error(), "non-finite") {
|
||||
t.Fatalf("a non-finite predictor: got %v, want the non-finite refusal", err)
|
||||
}
|
||||
if _, _, err := TheilSenRegression(x, mustFromFloats(t, []float64{0, 1, 2, math.Inf(1)}, 4)); err == nil || !strings.Contains(err.Error(), "non-finite") {
|
||||
t.Fatalf("a non-finite response: got %v, want the non-finite refusal", err)
|
||||
}
|
||||
if _, err := HuberRegression(x, y); err == nil || !strings.Contains(err.Error(), "must be rank 2") {
|
||||
t.Fatalf("a rank 1 design: got %v, want the rank refusal", err)
|
||||
}
|
||||
// One past the exactness contract: the refusal names the cost.
|
||||
big := make([]float64, TheilSenMaxObservations+1)
|
||||
for i := range big {
|
||||
big[i] = float64(i)
|
||||
}
|
||||
_, _, err := TheilSenRegression(mustFromFloats(t, big, len(big)), mustFromFloats(t, big, len(big)))
|
||||
if err == nil || !strings.Contains(err.Error(), "exactness contract") {
|
||||
t.Fatalf("a sample past the exactness contract: got %v, want the exactness refusal", err)
|
||||
}
|
||||
// At the contract itself the fit runs.
|
||||
if _, _, err := TheilSenRegression(mustFromFloats(t, big[:TheilSenMaxObservations], TheilSenMaxObservations), mustFromFloats(t, big[:TheilSenMaxObservations], TheilSenMaxObservations)); err != nil {
|
||||
t.Fatalf("a sample at the contract was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRobustEstimatorsOnIntegerArrays exercises the widening
|
||||
// accessor's fallback paths: an integer design and response reach the
|
||||
// estimators through FloatAt rather than a raw float payload, and the
|
||||
// fits must answer exactly as they do on the widened floats.
|
||||
func TestRobustEstimatorsOnIntegerArrays(t *testing.T) {
|
||||
design := mustFromInts(t, []int64{1, 0, 1, 1, 1, 2, 1, 3, 1, 4, 1, 5, 1, 6}, 7, 2)
|
||||
y := mustFromInts(t, []int64{2, 3, 4, 5, 6, 7, 8}, 7)
|
||||
huber, err := HuberRegressionTuned(design, y, DefaultHuberTuning)
|
||||
if err != nil {
|
||||
t.Fatalf("HuberRegression on integer input: %v", err)
|
||||
}
|
||||
if math.Abs(huber.Coefficients[0]-2) > 1e-9 || math.Abs(huber.Coefficients[1]-1) > 1e-9 {
|
||||
t.Fatalf("the integer-input Huber fit is (%.6f, %.6f), want (2, 1)",
|
||||
huber.Coefficients[0], huber.Coefficients[1])
|
||||
}
|
||||
x := mustFromInts(t, []int64{0, 1, 2, 3, 4, 5, 6}, 7)
|
||||
intercept, slope, err := TheilSenRegression(x, y)
|
||||
if err != nil {
|
||||
t.Fatalf("TheilSenRegression on integer input: %v", err)
|
||||
}
|
||||
if slope != 1 || intercept != 2 {
|
||||
t.Fatalf("the integer-input Theil-Sen fit is (%.17g, %.17g), want exactly (2, 1)", intercept, slope)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHuberValidation refuses the malformed inputs, tuning constants
|
||||
// included: the tuning constant is the estimator's shape, and a zero
|
||||
// or non-finite one has no Huber loss behind it.
|
||||
func TestHuberValidation(t *testing.T) {
|
||||
design := mustFromFloats(t, []float64{1, 0, 1, 1, 1, 2, 1, 3, 1, 4, 1, 5}, 6, 2)
|
||||
y := mustFromFloats(t, []float64{1, 3, 2, 5, 4, 7}, 6)
|
||||
if _, err := HuberRegressionTuned(design, y, 0); err == nil {
|
||||
t.Fatalf("a zero tuning constant was accepted")
|
||||
}
|
||||
if _, err := HuberRegressionTuned(design, y, -1); err == nil {
|
||||
t.Fatalf("a negative tuning constant was accepted")
|
||||
}
|
||||
if _, err := HuberRegressionTuned(design, y, math.NaN()); err == nil {
|
||||
t.Fatalf("a NaN tuning constant was accepted")
|
||||
}
|
||||
if _, err := HuberRegressionTuned(design, y, math.Inf(1)); err == nil {
|
||||
t.Fatalf("an infinite tuning constant was accepted")
|
||||
}
|
||||
if _, err := HuberRegressionTuned(design, mustFromFloats(t, []float64{1, 2, 3, math.NaN(), 5, 7}, 6), 1.345); err == nil {
|
||||
t.Fatalf("a non-finite response was accepted")
|
||||
}
|
||||
if _, err := HuberRegressionTuned(mustFromFloats(t, []float64{1, 0, 1, 1}, 2, 2), mustFromFloats(t, []float64{1, 2}, 2), 1.345); err == nil {
|
||||
t.Fatalf("n = p was accepted")
|
||||
}
|
||||
if _, err := HuberRegressionTuned(mustFromFloats(t, []float64{1, 1, 1, 2, 1, 3}, 3, 2), mustFromFloats(t, []float64{1, 2, 3, 4}, 4), 1.345); err == nil {
|
||||
t.Fatalf("a length mismatch was accepted")
|
||||
}
|
||||
complexDesign := core.New(core.Complex, 6, 2)
|
||||
if _, err := HuberRegressionTuned(complexDesign, y, 1.345); err == nil {
|
||||
t.Fatalf("complex input was accepted")
|
||||
}
|
||||
// A rank-deficient design is refused by the shared solve.
|
||||
singular := mustFromFloats(t, []float64{1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2}, 6, 2)
|
||||
if _, err := HuberRegressionTuned(singular, y, 1.345); err == nil {
|
||||
t.Fatalf("a rank-deficient design was accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user