Files

653 lines
22 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 (
"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
}