Files
tensor/stats/smallops_test.go
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

475 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package stats
import (
"math"
"slices"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
"sourcedock.dev/petrbalvin/tensor/internal/engine"
)
func smallVector(t *testing.T, vals []float64) *core.Array {
t.Helper()
a, err := core.FromFloats(vals, len(vals))
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
return a
}
// TestRollingFamily checks the window reductions on a known series:
// alignment, the shrinking length and each reduction's value.
func TestRollingFamily(t *testing.T) {
a := smallVector(t, []float64{1, 4, 2, 8, 5, 7})
mean, err := RollingMean(a, 3)
if err != nil {
t.Fatalf("RollingMean: %v", err)
}
if mean.Len() != 4 {
t.Fatalf("length %d, want 4", mean.Len())
}
wantMean := []float64{7.0 / 3, 14.0 / 3, 5, 20.0 / 3}
for i := range wantMean {
if math.Abs(mean.FloatAt(i)-wantMean[i]) > 1e-12 {
t.Fatalf("mean[%d] = %.12g, want %.12g", i, mean.FloatAt(i), wantMean[i])
}
}
sum, _ := RollingSum(a, 2)
if sum.FloatAt(0) != 5 || sum.FloatAt(4) != 12 {
t.Fatalf("sum ends = %g, %g, want 5, 12", sum.FloatAt(0), sum.FloatAt(4))
}
max, _ := RollingMax(a, 3)
if max.FloatAt(0) != 4 || max.FloatAt(3) != 8 {
t.Fatalf("max ends = %g, %g, want 4, 8", max.FloatAt(0), max.FloatAt(3))
}
min, _ := RollingMin(a, 2)
if min.FloatAt(2) != 2 || min.FloatAt(1) != 2 {
t.Fatalf("min = %g, %g, want 2, 2", min.FloatAt(1), min.FloatAt(2))
}
if _, err := RollingMean(a, 0); err == nil {
t.Fatal("zero window accepted")
}
if _, err := RollingMean(a, 7); err == nil {
t.Fatal("window past the length accepted")
}
}
// TestMedianAbsoluteDeviation checks the definition on a sample with
// one wild point: the breakdown-proof scale stays at 1, the middle
// deviation, where the standard deviation would explode.
func TestMedianAbsoluteDeviation(t *testing.T) {
a := smallVector(t, []float64{1, 2, 3, 4, 100})
mad, err := MedianAbsoluteDeviation(a)
if err != nil {
t.Fatalf("MedianAbsoluteDeviation: %v", err)
}
if mad != 1 {
t.Fatalf("MAD = %g, want 1", mad)
}
}
// TestTrimmedMean checks the tail fraction: dropping one sample a
// side (ten percent) of the contaminated sample discards both tails,
// leaving the clean mean of 2 to 9.
func TestTrimmedMean(t *testing.T) {
a := smallVector(t, []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 100})
mean, err := TrimmedMean(a, 0.1)
if err != nil {
t.Fatalf("TrimmedMean: %v", err)
}
if math.Abs(mean-5.5) > 1e-12 {
t.Fatalf("trimmed mean = %.12g, want 5.5", mean)
}
if _, err := TrimmedMean(a, 0.5); err == nil {
t.Fatal("half fraction accepted")
}
if _, err := TrimmedMean(a, -0.1); err == nil {
t.Fatal("negative fraction accepted")
}
}
// TestHistogram2D checks the count matrix on a constructed pair of
// samples, including the last-bin absorption of the maximum.
func TestHistogram2D(t *testing.T) {
x := smallVector(t, []float64{0.5, 1.5, 2.5, 3.5, 2.9})
y := smallVector(t, []float64{10, 20, 30, 40, 39})
counts, xEdges, yEdges, err := Histogram2D(x, y, 4, 2)
if err != nil {
t.Fatalf("Histogram2D: %v", err)
}
if counts.Shape()[0] != 4 || counts.Shape()[1] != 2 {
t.Fatalf("shape %v, want [4 2]", counts.Shape())
}
if len(xEdges) != 5 || len(yEdges) != 3 {
t.Fatalf("edges %d and %d, want 5 and 3", len(xEdges), len(yEdges))
}
// x range [0.5, 3.5] over 4 bins of width 0.75; y range [10, 40]
// over 2 bins of width 15.
want := map[[2]int]int{
{0, 0}: 1, // (0.5, 10)
{1, 0}: 1, // (1.5, 20)
{2, 1}: 1, // (2.5, 30)
{3, 1}: 2, // (2.9, 39) and (3.5, 40) in the last bins
}
ints := counts.RawInts()
for xi := range 4 {
for yi := range 2 {
got := int(ints[xi*2+yi])
if want[[2]int{xi, yi}] != got {
t.Fatalf("count[%d][%d] = %d, want %d", xi, yi, got, want[[2]int{xi, yi}])
}
}
}
if _, _, _, err := Histogram2D(smallVector(t, []float64{1}), y, 2, 2); err == nil {
t.Fatal("mismatched lengths accepted")
}
if _, _, _, err := Histogram2D(x, y, 0, 2); err == nil {
t.Fatal("zero bins accepted")
}
}
// TestWeightedLinearRegression checks the weighted fit on an exact
// line and on a sample whose one wild point the weights silence: the
// unweighted fit chases the outlier, the weighted one recovers the
// line the other three points share.
func TestWeightedLinearRegression(t *testing.T) {
design, err := core.FromFloats([]float64{1, 1, 1, 2, 1, 3, 1, 4}, 4, 2)
if err != nil {
t.Fatalf("design: %v", err)
}
y := smallVector(t, []float64{3, 5, 7, 9}) // y = 2x + 1 exactly
w := smallVector(t, []float64{1, 1, 1, 1})
res, err := WeightedLinearRegression(design, y, w)
if err != nil {
t.Fatalf("WeightedLinearRegression: %v", err)
}
if math.Abs(res.Coefficients[0]-1) > 1e-9 || math.Abs(res.Coefficients[1]-2) > 1e-9 {
t.Fatalf("coefficients = %g, %g, want 1, 2", res.Coefficients[0], res.Coefficients[1])
}
// The fourth observation is the outlier now; heavy weights on the
// clean points pull the fit back onto y = 2x + 1.
y2 := smallVector(t, []float64{3, 5, 7, 50})
w2 := smallVector(t, []float64{1000, 1000, 1000, 0.001})
res2, err := WeightedLinearRegression(design, y2, w2)
if err != nil {
t.Fatalf("WeightedLinearRegression: %v", err)
}
if math.Abs(res2.Coefficients[1]-2) > 1e-3 {
t.Fatalf("slope = %.6g, want 2 within the weights' pull", res2.Coefficients[1])
}
// Residuals read in the original units: the clean points sit on
// the line again.
if math.Abs(res2.Residuals[0]) > 1e-2 {
t.Fatalf("first residual = %.6g, want a clean-point zero", res2.Residuals[0])
}
if _, err := WeightedLinearRegression(design, y, smallVector(t, []float64{1, 1, 1, -1})); err == nil {
t.Fatal("negative weight accepted")
}
}
// TestHistogram2DParallelCounting pins the paired sweep's split: past
// the per-worker floor the sample is cut into chunks, each worker
// counts into a private matrix and the merge adds every chunk into the
// total. The counts are exact integers, so the split must produce the
// matrix the single-worker sweep produces, cell for cell.
func TestHistogram2DParallelCounting(t *testing.T) {
const n, xBins, yBins = 1 << 16, 16, 16
xv := make([]float64, n)
yv := make([]float64, n)
for i := range n {
xv[i] = float64((i*7919)%1000)/1000*4 - 2
yv[i] = float64((i*104729)%1000)/1000*6 - 3
}
xv[0], xv[1] = -2, 2
yv[0], yv[1] = -3, 3
x := smallVector(t, xv)
y := smallVector(t, yv)
prev := engine.SetNumWorkers(4)
defer engine.SetNumWorkers(prev)
if w := engine.WorkersFor(n); w < 2 {
t.Fatalf("the sweep did not split across workers: %d", w)
}
split, xEdges, yEdges, err := Histogram2D(x, y, xBins, yBins)
if err != nil {
t.Fatalf("Histogram2D across workers: %v", err)
}
engine.SetNumWorkers(1)
serial, _, _, err := Histogram2D(x, y, xBins, yBins)
if err != nil {
t.Fatalf("Histogram2D on one worker: %v", err)
}
if split.Len() != xBins*yBins {
t.Fatalf("the matrix holds %d cells, want %d", split.Len(), xBins*yBins)
}
got, want := split.RawInts(), serial.RawInts()
total := int64(0)
for i := range got {
if got[i] != want[i] {
t.Fatalf("cell %d = %d across workers, %d on one, want the same", i, got[i], want[i])
}
total += got[i]
}
if total != n {
t.Fatalf("the counts total %d, want %d", total, n)
}
// The binning arithmetic, recomputed from the returned edges.
xLo, xWidth := xEdges[0], xEdges[1]-xEdges[0]
yLo, yWidth := yEdges[0], yEdges[1]-yEdges[0]
ref := make([]int64, xBins*yBins)
for i := range n {
xi := int((xv[i] - xLo) / xWidth)
if xi >= xBins {
xi = xBins - 1
}
if xi < 0 {
xi = 0
}
yi := int((yv[i] - yLo) / yWidth)
if yi >= yBins {
yi = yBins - 1
}
if yi < 0 {
yi = 0
}
ref[xi*yBins+yi]++
}
for i := range xBins * yBins {
if got[i] != ref[i] {
t.Fatalf("cell %d = %d, the binning arithmetic gives %d", i, got[i], ref[i])
}
}
}
// refWindowExtreme is the retired window rescan: the seed walks to the
// first non-NaN sample and the rest folds with a strict comparison, so
// a tie keeps the earlier sample, the ±0 pair included, and an all-NaN
// window answers its last element. The monotonic deque must select the
// same sample, bit for bit.
func refWindowExtreme(src []float64, window int, greater bool) []float64 {
out := make([]float64, len(src)-window+1)
for i := range out {
w := src[i : i+window]
best, k := w[0], 1
for math.IsNaN(best) && k < len(w) {
best = w[k]
k++
}
for _, v := range w[k:] {
if greater {
if v > best {
best = v
}
} else {
if v < best {
best = v
}
}
}
out[i] = best
}
return out
}
// checkRollingRescan folds both window extrema over the series at every
// window and compares every output bit against the rescan.
func checkRollingRescan(t *testing.T, series []float64) {
t.Helper()
windows := make([]int, len(series))
for i := range windows {
windows[i] = i + 1
}
checkRollingRescanAt(t, series, windows...)
}
// checkRollingRescanAt folds both window extrema over the series at the
// given windows and compares every output bit against the rescan.
func checkRollingRescanAt(t *testing.T, series []float64, windows ...int) {
t.Helper()
for _, window := range windows {
for _, greater := range []bool{true, false} {
var got *core.Array
var err error
if greater {
got, err = RollingMax(smallVector(t, series), window)
} else {
got, err = RollingMin(smallVector(t, series), window)
}
if err != nil {
t.Fatalf("window %d over %v: %v", window, series, err)
}
want := refWindowExtreme(series, window, greater)
if got.Len() != len(want) {
t.Fatalf("window %d: %d outputs, want %d", window, got.Len(), len(want))
}
for i := range want {
if gb, wb := math.Float64bits(got.FloatAt(i)), math.Float64bits(want[i]); gb != wb {
// A long series is not worth printing: the index, the
// two values and the window say which sample the fold
// picked instead.
if len(series) > 16 {
t.Fatalf("window %d: [%d] = %v (%#x), the rescan gives %v (%#x) over a %d-sample series",
window, i, got.FloatAt(i), gb, want[i], wb, len(series))
}
t.Fatalf("window %d over %v: [%d] = %v (%#x), the rescan gives %v (%#x)",
window, series, i, got.FloatAt(i), gb, want[i], wb)
}
}
}
}
}
// TestRollingRescanTiesAndNaN pins the deque's selection rule against
// the retired window rescan on the samples where the rule is visible:
// a tie between +0 and −0 (the earlier sample wins, so the sign of the
// zero is part of the answer), NaN sharing a window with a number, an
// all-NaN window and the infinities.
func TestRollingRescanTiesAndNaN(t *testing.T) {
negZero, posZero := math.Copysign(0, -1), 0.0
checkRollingRescan(t, []float64{negZero, posZero})
checkRollingRescan(t, []float64{posZero, negZero})
checkRollingRescan(t, []float64{negZero, posZero, negZero, posZero})
checkRollingRescan(t, []float64{negZero, posZero, math.NaN()})
checkRollingRescan(t, []float64{math.NaN(), negZero, posZero})
checkRollingRescan(t, []float64{posZero, math.NaN(), negZero})
checkRollingRescan(t, []float64{math.NaN(), math.NaN(), math.NaN()})
checkRollingRescan(t, []float64{math.Inf(-1), math.Inf(1), negZero, posZero, math.NaN()})
}
// TestRollingRescanCompaction pins the deque's buffer compaction: once
// the dead prefix reaches rollingDequeCompact the buffer is compacted,
// and the compaction must drop dead indices only, never the live entry
// at the deque's front. The series is monotone with a NaN every
// threshold indices: the NaN leaves the front one index ahead of the
// window's own oldest, which is exactly the state a compaction that
// copies from the front instead of from the dead prefix destroys, and
// the window's extreme then comes back from the wrong sample.
func TestRollingRescanCompaction(t *testing.T) {
const n = 4 * rollingDequeCompact
series := make([]float64, n)
for i := range n {
if i > 0 && i%rollingDequeCompact == 0 {
series[i] = math.NaN()
} else {
series[i] = -float64(i)
}
}
checkRollingRescanAt(t, series, 2, 3, rollingDequeCompact)
}
func TestMedianSliceMatchesSort(t *testing.T) {
// The selection must answer what the sort answered. The pool holds no
// NaN: Go orders NaN nowhere, so "the sorted middle" is not a
// meaningful reference once one is present, and the medians that
// matter (slopes, residuals, deviations) are finite by the time they
// reach here. The zeros are in, because the two zeros are the one
// pair that compares equal while differing in bits, and that is the
// case the selection cannot pin.
pool := []float64{0, math.Copysign(0, -1), -1, 1, 2, -2, 0.5, -0.5, 3, -3, 7, 7, 7}
lens := []int{1, 2, 3, 4, 5, 9, 14, 25, 26, 27, 50, 100, 1000, 4097}
for _, n := range lens {
for shift := range len(pool) {
vals := make([]float64, n)
for i := range vals {
vals[i] = pool[(i+shift)%len(pool)]
}
want := append([]float64(nil), vals...)
slices.Sort(want)
var expected float64
if n%2 == 1 {
expected = want[n/2]
} else {
expected = want[n/2-1]/2 + want[n/2]/2
}
got := medianSlice(vals)
if n%2 == 1 {
// An odd length has no tie ambiguity at the middle: the
// value is unique, so the bits must agree outright.
if math.Float64bits(got) != math.Float64bits(expected) {
t.Errorf("n=%d shift=%d: median %v, sorted %v", n, shift, got, expected)
}
} else if got != expected {
t.Errorf("n=%d shift=%d: median %v, sorted %v", n, shift, got, expected)
}
// The selection leaves a permutation of the input. The two
// sorts used for the check are themselves unstable, so the
// comparison is by value and the negative zeros are counted
// separately: that is the one distinction a value comparison
// cannot see.
gotSorted := append([]float64(nil), vals...)
slices.Sort(gotSorted)
for i := range gotSorted {
if gotSorted[i] != want[i] {
t.Fatalf("n=%d shift=%d: elements changed at %d: %v against %v", n, shift, i, gotSorted[i], want[i])
}
}
if negZero(gotSorted) != negZero(want) {
t.Fatalf("n=%d shift=%d: negative zeros changed: %d against %d", n, shift, negZero(gotSorted), negZero(want))
}
}
}
}
func TestMedianSliceNaNStaysInTheInput(t *testing.T) {
// A NaN in the input is not the selection's business: the multiset
// must survive it and the median must be a value that was there.
vals := []float64{1, math.NaN(), 2, 3, 4}
got := medianSlice(vals)
if got == got {
found := false
for _, v := range []float64{1, 2, 3, 4} {
if got == v {
found = true
}
}
if !found {
t.Errorf("median %v is not an input value", got)
}
}
count := 0
for _, v := range vals {
if math.IsNaN(v) {
count++
}
}
if count != 1 {
t.Errorf("the selection lost the NaN: %d of them remain", count)
}
}
func TestMedianSliceOnDistinctValues(t *testing.T) {
// Distinct values have no tie ambiguity at all: the median must be the
// exact order statistic on every length.
for n := 1; n <= 200; n++ {
vals := make([]float64, n)
for i := range vals {
vals[i] = float64((i*37)%101) - 50
}
want := append([]float64(nil), vals...)
slices.Sort(want)
expected := want[n/2]
if n%2 == 0 {
expected = want[n/2-1]/2 + want[n/2]/2
}
if got := medianSlice(vals); got != expected {
t.Fatalf("n=%d: median %v, sorted %v", n, got, expected)
}
}
}
// negZero counts the negative zeros in a slice: the one distinction a
// value comparison cannot see.
func negZero(vals []float64) int {
n := 0
for _, v := range vals {
if v == 0 && math.Signbit(v) {
n++
}
}
return n
}