Files
tensor/stats/robustregression.go
T
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

653 lines
22 KiB
Go
Raw 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"
"sync"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
"sourcedock.dev/petrbalvin/tensor/internal/engine"
)
// Robust regression: the fits that survive the wild observations the
// classical least squares would chase. Two estimators live here. The
// Huber M-estimator bounds the influence of a gross outlier by
// replacing the squared loss with one that grows quadratically only
// inside a band around zero and linearly outside it, fitted by
// iteratively reweighted least squares. Theil-Sen replaces the whole
// least-squares machinery with the median of the pairwise slopes,
// which a minority of broken points cannot move.
// DefaultHuberTuning is the Huber tuning constant the plain
// HuberRegression entry uses. The value 1.345 is the literature's
// standard choice: with the band measured in robust standard
// deviations it puts the estimator at 95 percent asymptotic efficiency
// at the Gaussian while keeping the influence of an outlier bounded at
// 1.345 times what a residual inside the band would have.
const DefaultHuberTuning = 1.345
// huberMaxIterations bounds the reweighting loop, and the tolerance is
// the largest coefficient movement one iteration may leave behind for
// the fit to call itself settled. Both follow the glm.go convention.
const (
huberMaxIterations = 100
huberTolerance = 1e-10
)
// HuberRegressionResult carries a Huber M-estimate of the linear
// model.
type HuberRegressionResult struct {
// Coefficients are the M-estimates β̂, one per design column, in
// the design's own order.
Coefficients []float64
// StandardErrors are the classical asymptotic standard errors read
// from σ²·(XᵀWX)⁻¹, W the final weight vector: the same shape of
// statement WeightedLinearRegression makes, with the robust scale
// σ in place of the residual standard deviation.
StandardErrors []float64
// Weights are the final IRLS weights, one per observation: exactly
// 1 inside the band |r| ≤ k·σ and tapering as k·σ/|r| outside it.
// They are the diagnostic a robust fit exists to produce: the
// contaminated observations are the ones at the bottom of the
// list.
Weights []float64
// Scale is the final robust scale σ, 1.4826 times the median
// absolute deviation of the residuals, the estimate the band is
// measured in.
Scale float64
// Fitted and Residuals align with the rows of the design.
Fitted []float64
Residuals []float64
// Iterations counts the reweighting steps taken; Converged reports
// whether the coefficient updates fell under the tolerance.
Iterations int
Converged bool
}
// HuberRegression fits y = X·β with the Huber M-estimator at the
// default tuning constant. See HuberRegressionTuned for the full
// contract.
func HuberRegression(x, y *core.Array) (*HuberRegressionResult, error) {
return HuberRegressionTuned(x, y, DefaultHuberTuning)
}
// HuberRegressionTuned fits y = X·β by Huber's M-estimation with the
// tuning constant tuning: the loss is r²/2 inside the band |r| ≤
// tuning·σ and tuning·σ·(|r| − tuning·σ/2) outside it, so a wild
// observation pulls like a linear, not a quadratic, residual. The fit
// runs by iteratively reweighted least squares: ordinary least squares
// to start, then each round re-estimates the robust scale σ as 1.4826
// times the median absolute deviation of the current residuals,
// weights each observation by 1 inside the band and tuning·σ/|r|
// outside it, and solves the weighted normal equations until no
// coefficient moves by more than 1e-10.
//
// The design carries n rows and p columns exactly as
// LinearRegression's, the intercept included by the caller as a
// constant column when wanted, and the same validations apply: n > p,
// a full-rank design, real finite input, and a positive finite tuning
// constant. A robust scale that collapses to zero, more than half the
// residuals landing on one value, stops the iteration as an exact fit
// and is reported rather than divided by.
func HuberRegressionTuned(x, y *core.Array, tuning float64) (*HuberRegressionResult, error) {
const name = "HuberRegression"
if x.NDim() != 2 {
return nil, base.Errf("%s: the design must be rank 2, got shape %s", name, base.ShapeText(x.Shape()))
}
if y.NDim() != 1 {
return nil, base.Errf("%s: the response must be rank 1", name)
}
if x.Dtype() == core.Complex || y.Dtype() == core.Complex {
return nil, base.Errf("%s: complex inputs are not supported", name)
}
n, p := x.Shape()[0], x.Shape()[1]
if y.Len() != n {
return nil, base.Errf("%s: the design has %d rows but the response %d", name, n, y.Len())
}
if n <= p {
return nil, base.Errf("%s: need n > p, got %d observations and %d columns", name, n, p)
}
if p == 0 {
return nil, base.Errf("%s: the design must carry at least one column", name)
}
if err := checkFinite(name, "the design", x); err != nil {
return nil, err
}
if err := checkFinite(name, "the response", y); err != nil {
return nil, err
}
if math.IsNaN(tuning) || math.IsInf(tuning, 0) || tuning <= 0 {
return nil, base.Errf("%s: the tuning constant must be finite and positive, got %g", name, tuning)
}
fx := rawFloats(x)
fy := rawFloats(y)
// The start is the ordinary least squares answer: the M-estimator's
// own optimum is rarely far from it, and the reweighting does the
// rest. The normal equations go through the shared LU, exactly as
// LinearRegression solves them.
beta, err := leastSquaresSolve(name, n, p, x, y, fx, fy, nil)
if err != nil {
return nil, err
}
residuals := make([]float64, n)
regressionResiduals(n, p, x, y, fx, fy, beta, residuals)
scaleScratch := make([]float64, n)
// One workspace serves every reweighting round: the solve consumes
// its buffers in place and the next round clears them first.
ws := newLeastSquaresWorkspace(p)
out := &HuberRegressionResult{
Coefficients: beta,
Weights: make([]float64, n),
}
converged := false
iterations := huberMaxIterations
for iter := 1; iter <= huberMaxIterations; iter++ {
scale := huberScale(residuals, scaleScratch)
if scale == 0 {
// The robust scale has collapsed: more than half the
// residuals sit on one value, the band has nothing to
// widen, and the fit is as settled as it will ever be. The
// weights follow the collapsed band, one inside it and
// zero outside, and the loop stops rather than divide by
// the zero the tapering would need.
for i, r := range residuals {
out.Weights[i] = huberWeight(r, 0)
}
out.Scale = 0
converged = true
iterations = iter - 1
break
}
band := tuning * scale
for i, r := range residuals {
out.Weights[i] = huberWeight(r, band)
}
updated, err := leastSquaresSolveWS(name, n, p, x, y, fx, fy, out.Weights, ws)
if err != nil {
return nil, err
}
worst := 0.0
for j := range p {
if d := math.Abs(updated[j] - beta[j]); d > worst {
worst = d
}
beta[j] = updated[j]
}
regressionResiduals(n, p, x, y, fx, fy, beta, residuals)
if worst < huberTolerance {
out.Scale = huberScale(residuals, scaleScratch)
// The reported weights follow the answer, not the step
// that produced it: the collapsed-scale branch refreshes
// them, and the converged exit must agree with the Scale
// and Residuals it reports (huberWeight at band 0 is the
// collapsed rule, one inside and zero outside).
band := tuning * out.Scale
for i, r := range residuals {
out.Weights[i] = huberWeight(r, band)
}
converged = true
iterations = iter
break
}
}
if !converged {
return nil, base.Errf("%s: %d iterations did not converge", name, huberMaxIterations)
}
out.Iterations = iterations
out.Converged = converged
// Fitted and Residuals from the settled coefficients, and the
// standard errors from σ²·(XᵀWX)⁻¹, its diagonal read from one
// factorisation of the pristine normal equations against all p unit
// columns at once, the way LinearRegression reads its own
// covariance diagonal.
out.Fitted = make([]float64, n)
out.Residuals = make([]float64, n)
copy(out.Residuals, residuals)
weighted := out.Scale * out.Scale
wxxPristine := weightedNormal(n, p, x, fx, out.Weights)
out.StandardErrors = make([]float64, p)
unit := make([][]float64, p)
for j := range p {
unit[j] = make([]float64, p)
unit[j][j] = 1
}
inv, err := base.SolveSystem(name, wxxPristine, unit)
if err != nil {
return nil, base.Errf("%s: %w", name, err)
}
for j := range p {
v := weighted * inv[j][j]
switch {
case v > 0:
out.StandardErrors[j] = math.Sqrt(v)
case v == 0:
// An exact fit: nothing to estimate, a zero standard error
// beside the zero residual variance.
out.StandardErrors[j] = 0
default:
return nil, base.Errf("%s: the design is near-collinear: the variance of coefficient %d came out negative (%g)", name, j, v)
}
}
return out, nil
}
// huberWeight is the IRLS weight of one residual for a band: exactly
// one inside the band, the band over the magnitude outside it, which
// is the taper that turns the quadratic loss linear. A collapsed band
// leaves the zero residual at weight one and everything else at zero.
func huberWeight(residual, band float64) float64 {
a := math.Abs(residual)
if a <= band {
return 1
}
return band / a
}
// huberScale estimates the robust scale of a residual sample as
// 1.4826 times the median absolute deviation, the constant that makes
// the estimate consistent for the standard deviation of Gaussian
// residuals. It is scale-free against a shift because the median is
// taken twice, once at the centre and once over the deviations.
//
// scratch is a buffer as long as residuals, reused across the
// reweighting rounds: the centre pass sorts it, and the deviation pass
// then writes every slot of it before the second median reads any, so
// the rounds cannot see each other's values.
func huberScale(residuals, scratch []float64) float64 {
vals := scratch[:len(residuals)]
copy(vals, residuals)
centre := medianSlice(vals)
for i, r := range residuals {
vals[i] = math.Abs(r - centre)
}
return 1.4826 * medianSlice(vals)
}
// medianSlice rearranges vals in place and returns their median,
// averaging the two middle values on even length exactly as Median does:
// the halved-magnitudes sum, immune to the overflow the literal average
// risks. Callers hand over a disposable slice.
//
// The two middle order statistics come from a selection, not from a sort:
// at the Theil-Sen observation cap the slope list holds millions of
// entries and the sort cost more than everything else in the fit put
// together. The selected values are the ones the sort left at the middle
// indices, with one honest difference: a sort is unstable, so among values
// that compare equal but differ in bits (the two zeros, a NaN payload) the
// sort's choice of which one lands at the middle is unspecified, and the
// selection may return the other. Every other input answers the identical
// number.
func medianSlice(vals []float64) float64 {
n := len(vals)
if n%2 == 1 {
selectNth(vals, n/2)
return vals[n/2]
}
selectNth(vals, n/2)
// Everything before index n/2 is no larger than the upper middle, so
// the lower middle is the largest of that prefix.
lo := vals[0]
for _, v := range vals[:n/2] {
if v > lo {
lo = v
}
}
return lo/2 + vals[n/2]/2
}
// selectNth rearranges vals so that the element at index k is the k-th
// smallest, every element before it is no larger, and every element after
// it is no smaller, using Hoare's partition with a median-of-three pivot
// and an insertion sort for short ranges. The pivot choice and the range
// cutoff decide speed only: whichever pivot is taken, the value that ends
// up at k is the k-th order statistic.
func selectNth(vals []float64, k int) {
lo, hi := 0, len(vals)-1
for hi-lo > 24 {
mid := lo + (hi-lo)/2
if vals[mid] < vals[lo] {
vals[mid], vals[lo] = vals[lo], vals[mid]
}
if vals[hi] < vals[lo] {
vals[hi], vals[lo] = vals[lo], vals[hi]
}
if vals[hi] < vals[mid] {
vals[hi], vals[mid] = vals[mid], vals[hi]
}
pivot := vals[mid]
i, j := lo, hi
for i <= j {
for vals[i] < pivot {
i++
}
for pivot < vals[j] {
j--
}
if i <= j {
vals[i], vals[j] = vals[j], vals[i]
i++
j--
}
}
if k <= j {
hi = j
continue
}
if k >= i {
lo = i
continue
}
return
}
for i := lo + 1; i <= hi; i++ {
v := vals[i]
j := i - 1
for j >= lo && v < vals[j] {
vals[j+1] = vals[j]
j--
}
vals[j+1] = v
}
}
// regressionResiduals recomputes r = y − Xβ into residuals.
func regressionResiduals(n, p int, x, y *core.Array, fx, fy []float64, beta, residuals []float64) {
for i := range n {
f := 0.0
if fx != nil {
row := fx[i*p : i*p+p]
for j, xj := range row {
f += beta[j] * xj
}
} else {
for j := range p {
f += beta[j] * x.FloatAt(i*p+j)
}
}
var yv float64
if fy != nil {
yv = fy[i]
} else {
yv = y.FloatAt(i)
}
residuals[i] = yv - f
}
}
// weightedNormal assembles the weighted normal equations matrix
// XᵀWX from a weight vector, or the unweighted XᵀX when w is nil.
// The caller consumes the matrix through the shared solve, which
// factors in place, so every call builds fresh.
func weightedNormal(n, p int, x *core.Array, fx []float64, w []float64) [][]float64 {
m := make([][]float64, p)
for i := range p {
m[i] = make([]float64, p)
}
weightedNormalInto(m, n, p, x, fx, w)
return m
}
// weightedNormalInto assembles XᵀWX (or XᵀX when w is nil) into the
// provided matrix, which the caller has cleared: every entry
// accumulates from zero, so a cleared buffer answers exactly what a
// fresh allocation answered. The accumulation order is the row walk's
// own, unchanged.
func weightedNormalInto(m [][]float64, n, p int, x *core.Array, fx []float64, w []float64) {
for r := range n {
wr := 1.0
if w != nil {
wr = w[r]
}
if fx != nil {
row := fx[r*p : r*p+p]
for a, xa := range row {
ga := wr * xa
ma := m[a]
for b, xb := range row {
ma[b] += ga * xb
}
}
} else {
for a := range p {
xa := x.FloatAt(r*p + a)
ga := wr * xa
for b := range p {
m[a][b] += ga * x.FloatAt(r*p+b)
}
}
}
}
}
// leastSquaresWorkspace carries the normal-equation buffers one
// reweighting loop refills: the shared solve factors its matrix and
// overwrites its right-hand side in place, so every round clears the
// same backing arrays and the accumulation sees the zero state a fresh
// allocation carried.
type leastSquaresWorkspace struct {
mat [][]float64
rhs []float64
}
func newLeastSquaresWorkspace(p int) *leastSquaresWorkspace {
m := make([][]float64, p)
for i := range p {
m[i] = make([]float64, p)
}
return &leastSquaresWorkspace{mat: m, rhs: make([]float64, p)}
}
// leastSquaresSolve solves the (weighted) normal equations in one
// shot: XᵀWX β = XᵀWy with W the weights, or the plain XᵀX system
// when w is nil. The right-hand side is assembled alongside the
// matrix so both see the same weights. The returned slice is the
// workspace's right-hand side, overwritten in place by the solve: the
// caller must consume it before the workspace is refilled.
func leastSquaresSolve(name string, n, p int, x, y *core.Array, fx, fy []float64, w []float64) ([]float64, error) {
return leastSquaresSolveWS(name, n, p, x, y, fx, fy, w, newLeastSquaresWorkspace(p))
}
// leastSquaresSolveWS is leastSquaresSolve on a caller-owned
// workspace, for a reweighting loop that solves once per round.
func leastSquaresSolveWS(name string, n, p int, x, y *core.Array, fx, fy []float64, w []float64, ws *leastSquaresWorkspace) ([]float64, error) {
for i := range p {
clear(ws.mat[i])
}
weightedNormalInto(ws.mat, n, p, x, fx, w)
rhs := ws.rhs
clear(rhs)
for r := range n {
wr := 1.0
if w != nil {
wr = w[r]
}
var yv float64
if fy != nil {
yv = fy[r]
} else {
yv = y.FloatAt(r)
}
g := wr * yv
if fx != nil {
row := fx[r*p : r*p+p]
for a, xa := range row {
rhs[a] += xa * g
}
} else {
for a := range p {
rhs[a] += x.FloatAt(r*p+a) * g
}
}
}
solved, err := base.SolveSystem(name, ws.mat, [][]float64{rhs})
if err != nil {
return nil, base.Errf("%s: the design is rank deficient (%w)", name, err)
}
return solved[0], nil
}
// TheilSenMaxObservations is the exactness contract of
// TheilSenRegression: the median of the pairwise slopes is computed
// over all n(n−1)/2 of them, which at 4096 observations is already
// some eight million slopes and a good fraction of a gigabyte of
// working memory. Beyond the cap the estimator refuses rather than
// silently degrade to a sample of itself; the cost is named in the
// error so the caller can subsample deliberately.
const TheilSenMaxObservations = 4096
// theilSenParallelPairs is the pairwise-slope count from which the walk
// splits across workers: below it a crew costs more to start than the
// walk it would carry, and above it a worker is handed this many
// slopes, so the number of blocks follows the pair count rather than
// the row count.
const theilSenParallelPairs = 1 << 18
// theilSenSlopePoolMax bounds the pair-slope buffer the pool keeps, in
// float64 entries. The cap-sized fit needs 8,386,560 of them, inside
// the bound; a longer list allocates fresh and is dropped on return,
// so one oversized call cannot pin a larger buffer on every
// processor, and sync.Pool forgets what it holds at each garbage
// collection besides.
const theilSenSlopePoolMax = 1 << 23
// theilSenSlopes is the pooled pair-slope buffer. The pointer form
// keeps the Put from boxing a slice header on every return.
type theilSenSlopes struct{ s []float64 }
var theilSenSlopePool = sync.Pool{
New: func() any { return new(theilSenSlopes) },
}
// TheilSenRegression fits the simple linear model y = a + b·x by the
// Theil-Sen estimator: the slope is the median of the pairwise slopes
// (y_j − y_i)/(x_j − x_i) over all pairs with distinct predictors,
// and the intercept is the median of y_i − b·x_i at that slope. Both
// medians are exact: the slope breaks down only when nearly half the
// points are broken, and one wild observation among hundreds cannot
// drag the answer at all. At least three observations with at least
// two distinct predictors are needed; every input must be finite, and
// samples beyond TheilSenMaxObservations are refused with the cost
// named rather than answered approximately.
func TheilSenRegression(x, y *core.Array) (intercept, slope float64, err error) {
const name = "TheilSenRegression"
if x.NDim() != 1 {
return 0, 0, base.Errf("%s: the predictor must be rank 1, got shape %s", name, base.ShapeText(x.Shape()))
}
if y.NDim() != 1 {
return 0, 0, base.Errf("%s: the response must be rank 1", name)
}
if x.Dtype() == core.Complex || y.Dtype() == core.Complex {
return 0, 0, base.Errf("%s: complex inputs are not supported", name)
}
n := x.Len()
if y.Len() != n {
return 0, 0, base.Errf("%s: the predictor has %d samples but the response %d", name, n, y.Len())
}
if n < 3 {
return 0, 0, base.Errf("%s: at least three observations are needed, got %d", name, n)
}
if n > TheilSenMaxObservations {
return 0, 0, base.Errf("%s: %d observations would need the exact median over %d pairwise slopes; the exactness contract ends at %d, subsample deliberately instead",
name, n, n*(n-1)/2, TheilSenMaxObservations)
}
if err := checkFinite(name, "the predictor", x); err != nil {
return 0, 0, err
}
if err := checkFinite(name, "the response", y); err != nil {
return 0, 0, err
}
xs := make([]float64, n)
ys := make([]float64, n)
if fx := rawFloats(x); fx != nil {
copy(xs, fx)
} else {
for i := range n {
xs[i] = x.FloatAt(i)
}
}
if fy := rawFloats(y); fy != nil {
copy(ys, fy)
} else {
for i := range n {
ys[i] = y.FloatAt(i)
}
}
// All pairwise slopes over the pairs whose predictor actually
// differs: a repeated predictor carries no slope information, and
// dividing by its zero would poison the median. The slopes land in
// one buffer in the row walk's own order, so the rows can be filled
// by a crew and the collected slice is the serial walk's own,
// element for element; the per-row pair counts are what let the
// blocks be cut before the walk starts.
counts := make([]int, n)
seen := make(map[float64]int, n)
for i := n - 1; i >= 0; i-- {
equal := seen[xs[i]]
seen[xs[i]] = equal + 1
counts[i] = n - 1 - i - equal
}
offsets := make([]int, n+1)
for i := range n {
offsets[i+1] = offsets[i] + counts[i]
}
kept := offsets[n]
if kept == 0 {
return 0, 0, base.Errf("%s: the predictor does not vary, no slope exists", name)
}
sb := theilSenSlopePool.Get().(*theilSenSlopes)
slopes := sb.s
if cap(slopes) < kept {
slopes = make([]float64, kept)
}
slopes = slopes[:kept]
fill := func(lo, hi int) {
for i := lo; i < hi; i++ {
off := offsets[i]
xi, yi := xs[i], ys[i]
for j := i + 1; j < n; j++ {
if dx := xs[j] - xi; dx != 0 {
slopes[off] = (ys[j] - yi) / dx
off++
}
}
}
}
// The walk's cost falls with i, so an even split of the rows would
// leave the first worker with a quarter of the work: the blocks are
// cut where the pair count crosses an equal share instead.
parts := min(kept/theilSenParallelPairs, n)
if parts < 2 {
fill(0, n)
} else {
per := kept / parts
bounds := make([]int, 1, parts+1)
for i, cut := 1, per; i < n && len(bounds) < parts; i++ {
if offsets[i] >= cut {
bounds = append(bounds, i)
cut += per
}
}
bounds = append(bounds, n)
engine.Parallel(len(bounds)-1, func(start, end int) {
for k := start; k < end; k++ {
fill(bounds[k], bounds[k+1])
}
})
}
slope = medianSlice(slopes)
if cap(slopes) <= theilSenSlopePoolMax {
sb.s = slopes[:cap(slopes)]
theilSenSlopePool.Put(sb)
}
intercepts := make([]float64, n)
for i := range n {
intercepts[i] = ys[i] - slope*xs[i]
}
return medianSlice(intercepts), slope, nil
}