653 lines
22 KiB
Go
653 lines
22 KiB
Go
// 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
|
||
}
|