Files
tensor/stats/inference.go
T

433 lines
14 KiB
Go
Raw Permalink 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 (
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
import (
"math"
"slices"
)
// Statistical inference on samples: association matrices, group
// comparison, goodness of fit, distribution comparison and the
// percentile bootstrap. Everything here is classical frequentist
// tooling built on the distribution functions of cdf.go, so a
// p-value travels no further than the library's own incomplete
// gamma and beta.
// CovarianceMatrix returns the sample covariance matrix of the
// observations in a, an (n, p) array whose rows are observations and
// columns variables: the (p, p) result has cov(i, j) the sample
// covariance of columns i and j with the 1/(n−1) normalisation. At
// least two observations are needed.
func CovarianceMatrix(a *core.Array) (*core.Array, error) {
return scatterMatrix("CovarianceMatrix", a, false)
}
// CorrelationMatrix returns the Pearson correlation matrix of the
// observations in a, the covariance matrix normalised by each
// column's sample standard deviation; the diagonal is exactly 1.
func CorrelationMatrix(a *core.Array) (*core.Array, error) {
return scatterMatrix("CorrelationMatrix", a, true)
}
// scatterMatrix builds the covariance or correlation matrix of an
// (n, p) observation array.
func scatterMatrix(name string, a *core.Array, correlate bool) (*core.Array, error) {
if a.NDim() != 2 {
return nil, base.Errf("%s: needs a 2-D array of observations, got shape %s",
name, base.ShapeText(a.Shape()))
}
n, p := a.Shape()[0], a.Shape()[1]
if n < 2 {
return nil, base.Errf("%s: at least two observations are needed, got %d", name, n)
}
if a.Dtype() == core.Complex {
return nil, base.Errf("%s: complex observations are not supported", name)
}
if err := checkFinite(name, "the observations", a); err != nil {
return nil, err
}
// Column means, then centred column copies. A dense float64 payload
// is read straight from the raw slice, one dispatch-free pass.
rows := rawFloats(a)
means := make([]float64, p)
for j := range p {
s := 0.0
if rows != nil {
for i := range n {
s += rows[i*p+j]
}
} else {
for i := range n {
s += a.FloatAt(i*p + j)
}
}
means[j] = s / float64(n)
}
cols := make([][]float64, p)
for j := range p {
cols[j] = make([]float64, n)
if rows != nil {
for i := range n {
cols[j][i] = rows[i*p+j] - means[j]
}
} else {
for i := range n {
cols[j][i] = a.FloatAt(i*p+j) - means[j]
}
}
}
scale := float64(n - 1)
cov := make([]float64, p*p)
stds := make([]float64, p)
for i := range p {
for j := i; j < p; j++ {
s := 0.0
for k := range n {
s += cols[i][k] * cols[j][k]
}
cov[i*p+j] = s / scale
cov[j*p+i] = cov[i*p+j]
}
if correlate {
stds[i] = math.Sqrt(cov[i*p+i])
if stds[i] == 0 {
return nil, base.Errf("CorrelationMatrix: column %d has zero variance", i)
}
}
}
if correlate {
for i := range p {
for j := i; j < p; j++ {
c := cov[i*p+j] / (stds[i] * stds[j])
cov[i*p+j] = c
cov[j*p+i] = c
}
cov[i*p+i] = 1
}
}
return floatsToArray(cov, []int{p, p}), nil
}
// WelchTTest compares two independent samples with Welch's t-test,
// which asks no equal-variance assumption: the statistic is
// (mean(a) − mean(b)) over the pooled standard error of the two
// means, and the degrees of freedom follow the Welch-Satterthwaite
// estimate. Returns the statistic, the (generally fractional) degrees
// of freedom and the two-sided p-value. Both samples need at least
// two observations.
func WelchTTest(a, b *core.Array) (t, df, pValue float64, err error) {
m1, v1, n1, err := sampleMeanVar(a, "WelchTTest")
if err != nil {
return 0, 0, 0, err
}
m2, v2, n2, err := sampleMeanVar(b, "WelchTTest")
if err != nil {
return 0, 0, 0, err
}
se1 := v1 / float64(n1)
se2 := v2 / float64(n2)
se := se1 + se2
if se == 0 {
return 0, 0, 0, base.Errf("WelchTTest: both samples have zero variance")
}
t = (m1 - m2) / math.Sqrt(se)
df = se * se / (se1*se1/float64(n1-1) + se2*se2/float64(n2-1))
// Two-sided p = P(T > |t|) + P(T < −|t|) = I_z(df/2, 1/2) with
// z = df/(df + t²), the Student-t tail in closed form.
z := df / (df + t*t)
pValue, err = BetaIncomplete(z, df/2, 0.5)
if err != nil {
return 0, 0, 0, base.Errf("WelchTTest: %w", err)
}
return t, df, pValue, nil
}
// sampleMeanVar returns the mean and the 1/(n−1) variance of a sample.
func sampleMeanVar(a *core.Array, name string) (mean, variance float64, n int, err error) {
if a.Dtype() == core.Complex {
return 0, 0, 0, base.Errf("%s: complex samples are not supported", name)
}
n = a.Len()
if n < 2 {
return 0, 0, 0, base.Errf("%s: at least two observations are needed, got %d", name, n)
}
// The finiteness refusal belongs here, before the arithmetic: a NaN
// sample would otherwise surface only when the tail function
// rejects the NaN statistic it produced, under the tail function's
// own name.
if err := checkFinite(name, "the sample", a); err != nil {
return 0, 0, 0, err
}
mean = 0.0
// The walk is bounded by the sample's own element count: a rebased
// view's payload may run past its visible elements, and those
// invisible tail slots are nobody's observations.
if fs := rawFloats(a); fs != nil {
fs = fs[:n]
for _, v := range fs {
mean += v
}
mean /= float64(n)
variance = sqDeviations(fs, mean)
} else {
for i := range n {
mean += a.FloatAt(i)
}
mean /= float64(n)
variance = sqDeviationsAt(a, mean)
}
variance /= float64(n - 1)
return mean, variance, n, nil
}
// ChiSquareGoodnessOfFit runs Pearson's test of an observed frequency
// table against expected ones: the statistic is Σ(O−E)²/E over the
// bins, the degrees of freedom the bin count minus one, and the
// p-value the upper tail of that χ² distribution. Every expected
// entry must be positive.
func ChiSquareGoodnessOfFit(observed, expected *core.Array) (chi2 float64, df int, pValue float64, err error) {
if observed.Dtype() == core.Complex || expected.Dtype() == core.Complex {
return 0, 0, 0, base.Errf("ChiSquareGoodnessOfFit: complex arrays are not supported")
}
n := observed.Len()
if expected.Len() != n {
return 0, 0, 0, base.Errf("ChiSquareGoodnessOfFit: observed has %d bins, expected %d",
n, expected.Len())
}
if n < 2 {
return 0, 0, 0, base.Errf("ChiSquareGoodnessOfFit: at least two bins are needed, got %d", n)
}
// A NaN observation would only surface later, rejected by the
// tail function under its own name; the scan keeps the error with
// the entry point the caller actually used.
if err := checkFinite("ChiSquareGoodnessOfFit", "the observed frequencies", observed); err != nil {
return 0, 0, 0, err
}
// A non-finite expectation passes the positivity gate below (+Inf
// is positive) and turns the statistic into a NaN the tail function
// would reject under its own, misleading, name.
if err := checkFinite("ChiSquareGoodnessOfFit", "the expected frequencies", expected); err != nil {
return 0, 0, 0, err
}
chi2 = 0.0
// Both walks are bounded by the bin count: a rebased view's payload
// may run past its visible bins, and indexing one past the other's
// payload would be a panic before it was a wrong statistic.
obs := rawFloats(observed)
exp := rawFloats(expected)
if obs != nil {
obs = obs[:n]
}
if exp != nil {
exp = exp[:n]
}
if obs != nil && exp != nil {
for i, e := range exp {
if !(e > 0) {
return 0, 0, 0, base.Errf("ChiSquareGoodnessOfFit: expected[%d] = %g must be positive", i, e)
}
d := obs[i] - e
chi2 += d * d / e
}
} else {
for i := range n {
e := expected.FloatAt(i)
if !(e > 0) {
return 0, 0, 0, base.Errf("ChiSquareGoodnessOfFit: expected[%d] = %g must be positive", i, e)
}
d := observed.FloatAt(i) - e
chi2 += d * d / e
}
}
df = n - 1
pValue, err = GammaUpper(float64(df)/2, chi2/2)
if err != nil {
return 0, 0, 0, base.Errf("ChiSquareGoodnessOfFit: %w", err)
}
return chi2, df, pValue, nil
}
// KolmogorovSmirnovTest compares two samples by the largest vertical
// distance d between their empirical distribution functions. The
// p-value is the asymptotic Kolmogorov distribution evaluated at
// λ = d·√(nm/(n+m)), accurate for samples of a few dozen and upward;
// small samples only get an order-of-magnitude answer. Both samples
// must be finite: a NaN never compares true against the support point
// the merge walk advances on, so it would leave the walk stuck, and an
// ±Inf would distort the distance.
func KolmogorovSmirnovTest(a, b *core.Array) (d, pValue float64, err error) {
const name = "KolmogorovSmirnovTest"
if a.Len() == 0 || b.Len() == 0 {
return 0, 0, base.Errf("%s: both samples must be non-empty", name)
}
if a.Dtype() == core.Complex || b.Dtype() == core.Complex {
return 0, 0, base.Errf("%s: complex samples are not supported", name)
}
if err := checkFinite(name, "the first sample", a); err != nil {
return 0, 0, err
}
if err := checkFinite(name, "the second sample", b); err != nil {
return 0, 0, err
}
xs := make([]float64, a.Len())
if fs := rawFloats(a); fs != nil {
copy(xs, fs)
} else {
for i := range a.Len() {
xs[i] = a.FloatAt(i)
}
}
ys := make([]float64, b.Len())
if fs := rawFloats(b); fs != nil {
copy(ys, fs)
} else {
for i := range b.Len() {
ys[i] = b.FloatAt(i)
}
}
slices.Sort(xs)
slices.Sort(ys)
// Walk the merged support, tracking both empirical CDFs.
i, j := 0, 0
d = 0.0
for i < len(xs) || j < len(ys) {
var x float64
switch {
case i < len(xs) && (j >= len(ys) || xs[i] <= ys[j]):
x = xs[i]
default:
x = ys[j]
}
for i < len(xs) && xs[i] <= x {
i++
}
for j < len(ys) && ys[j] <= x {
j++
}
if gap := math.Abs(float64(i)/float64(len(xs)) - float64(j)/float64(len(ys))); gap > d {
d = gap
}
}
en := math.Sqrt(float64(a.Len()) * float64(b.Len()) / float64(a.Len()+b.Len()))
pValue = kolmogorovTail(d * en)
return d, pValue, nil
}
// kolmogorovEps is the magnitude at which a term of the Kolmogorov
// series is below the rounding level of the sum, so the alternating
// series can be truncated there: the first omitted term bounds the
// truncation error, and the terms decrease from the first one on.
const kolmogorovEps = 1e-18
// maxKolmogorovTerms caps the series for the smallest lambdas that
// reach it at all.
const maxKolmogorovTerms = 100
// kolmogorovTermCount returns how many terms of the Kolmogorov series
// matter at lambda: the first k whose term 2·exp(−2k²λ²) has fallen
// below the rounding level. The series itself sums exactly that many,
// so the count is the truncation rule in one place instead of a
// condition the loop cannot reach.
func kolmogorovTermCount(lambda float64) int {
for k := 1; k <= maxKolmogorovTerms; k++ {
if 2*math.Exp(-2*float64(k)*float64(k)*lambda*lambda) < kolmogorovEps {
return k
}
}
return maxKolmogorovTerms
}
// kolmogorovTail evaluates the asymptotic Kolmogorov distribution
// Q(λ) = 2·Σ_{k≥1} (−1)^{k−1}·e^{−2k²λ²}, summing the terms that
// matter.
func kolmogorovTail(lambda float64) float64 {
if lambda < 0.2 {
return 1
}
total := 0.0
for k := 1; k <= kolmogorovTermCount(lambda); k++ {
term := 2 * math.Exp(-2*float64(k)*float64(k)*lambda*lambda)
if k%2 == 0 {
total -= term
} else {
total += term
}
}
return min(1, max(0, total))
}
// BootstrapCI estimates a confidence interval of a statistic by the
// percentile bootstrap: resamples the data with replacement
// resamples times from the seeded generator, evaluates the statistic
// on every resample and returns the alpha/2 and 1−alpha/2 quantiles
// of the bootstrap distribution, alpha = 1 − level. The statistic
// receives the resample as a fresh array and may return its own
// error. The data must be real-valued.
func BootstrapCI(data *core.Array, statistic func(*core.Array) (float64, error), level float64,
resamples int, seed int64) (lower, upper float64, err error) {
if data.Len() == 0 {
return 0, 0, base.Errf("BootstrapCI: the data must not be empty")
}
if data.Dtype() == core.Complex {
return 0, 0, base.Errf("BootstrapCI: complex data are not supported")
}
if !(level > 0 && level < 1) {
return 0, 0, base.Errf("BootstrapCI: level must lie in (0, 1), got %g", level)
}
if resamples < 2 {
return 0, 0, base.Errf("BootstrapCI: resamples must be ≥ 2, got %d", resamples)
}
g := core.NewGenerator(seed)
n := data.Len()
// The source values are read once and the index payload hoisted:
// the resample loop then indexes plain slices.
dataVals := rawFloats(data)
values := make([]float64, resamples)
for r := range resamples {
idx, ierr := core.Ints(g, n, 0, int64(n))
if ierr != nil {
return 0, 0, base.Errf("BootstrapCI: %w", ierr)
}
idxVals := idx.RawInts()
// A fresh slice per resample honours the documented contract: a
// statistic that retains its argument must not observe the next
// resample's mutation through the alias, and the resample is
// written into that slice directly rather than into a scratch the
// caller then copies.
sample := make([]float64, n)
if dataVals != nil {
for i := range n {
sample[i] = dataVals[idxVals[i]]
}
} else {
for i := range n {
sample[i] = data.FloatAt(int(idxVals[i]))
}
}
v, serr := statistic(wrapVector(sample))
if serr != nil {
return 0, 0, base.Errf("BootstrapCI: %w", serr)
}
values[r] = v
}
alpha := (1 - level) / 2
qs, qerr := Quantile(floatsToArray(values, []int{resamples}), []float64{alpha, 1 - alpha})
if qerr != nil {
return 0, 0, base.Errf("BootstrapCI: %w", qerr)
}
return qs.FloatAt(0), qs.FloatAt(1), nil
}
// wrapVector views a float64 slice as a rank-1 Array without copying.
func wrapVector(v []float64) *core.Array {
a, _ := core.FloatsFromArray(v, len(v))
return a
}