Files
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

477 lines
17 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 signal
import (
"math"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Time-series model estimation: the autoregressive and
// ARMA staples. The pure AR fit solves the Yule-Walker equations by
// the Durbin-Levinson recursion over the biased autocovariance, the
// small solver this package's own PartialAutocorrelate runs in
// miniature (the recursion lives locally here; the domain boundary
// allows no linalg import, and the earlier recursion answers a
// different question). The mixed model is fitted by the
// Hannan-Rissanen innovations method: a high-order autoregression
// first, whose residuals stand in for the unobserved innovations,
// then one least-squares regression of the series on its own lags and
// the lagged residuals. Both report the concentrated Gaussian
// likelihood of their innovations, and with it the AIC and BIC the
// grid search in SelectARMA minimises.
// ARMAResult holds one fitted time-series model. The coefficients
// follow the convention x_t − μ = Σ_j φ_j·(x_{t−j} − μ) + e_t +
// Σ_k θ_k·e_{t−k}, innovations white with variance
// InnovationVariance.
type ARMAResult struct {
// AR holds φ_1..φ_P, empty only for a pure MA model.
AR []float64
// MA holds θ_1..θ_Q, empty only for a pure AR model.
MA []float64
// InnovationVariance is the estimated variance σ² of e_t.
InnovationVariance float64
// Mean is the sample mean μ the fit removed and reports back.
Mean float64
// LogLikelihood is the concentrated Gaussian likelihood of the
// innovations over the whole series: −n/2·(log 2πσ² + 1) at the
// fitted innovation variance, so the criteria of different models
// on one series sit on a common footing.
LogLikelihood float64
// AIC is −2·LogLikelihood + 2k with k = P + Q + 1, the variance
// counted as a parameter.
AIC float64
// BIC is −2·LogLikelihood + k·log n, the same k against the
// innovation count's logarithm.
BIC float64
// P and Q record the orders the result carries.
P int
Q int
}
// ARMAOptions tunes the Hannan-Rissanen estimation and the grid
// search. HighAROrder is the order of the proxy autoregression whose
// residuals supply the MA stage; the zero value implies the default
// max(16, p+q+8), which the data length bounds. Criterion names the
// information criterion SelectARMA minimises: "aic" or "bic", with
// "aic" implied by the zero value.
type ARMAOptions struct {
HighAROrder int
Criterion string
}
// armaLikelihood fills the concentrated likelihood and the criteria
// from the innovation variance, evaluated over the whole series: nEff
// is the same sample count for every model fitted to one series,
// which is what makes the criteria comparable across a grid, and k
// counts the coefficients plus the variance. The values a fit
// conditions on (an AR's first p samples, the Hannan-Rissanen
// window's proxy residuals) are estimation detail, not a data
// difference.
func armaLikelihood(res *ARMAResult, nEff int) {
k := res.P + res.Q + 1
logL := -0.5 * float64(nEff) * (math.Log(2*math.Pi) + math.Log(res.InnovationVariance) + 1)
res.LogLikelihood = logL
res.AIC = 2*float64(k) - 2*logL
res.BIC = math.Log(float64(nEff))*float64(k) - 2*logL
}
// armaAutocovariance returns the biased autocovariances r_0..r_maxLag
// of a real rank-1 series, the estimator the Yule-Walker theory is
// written for: the mean removed, every lag divided by the sample
// count. The series' values are read through the house autocorrelation
// and rescaled by the lag-zero power. The mean comes back too, because
// every model is fitted to the demeaned series and reported with it.
func armaAutocovariance(name string, x *core.Array, maxLag int) (r []float64, n int, mean float64, err error) {
if x.NDim() != 1 {
return nil, 0, 0, base.Errf("%s: needs a rank-1 series, got shape %s", name, base.ShapeText(x.Shape()))
}
if x.Dtype() == core.Complex {
return nil, 0, 0, base.Errf("%s: complex series are not supported", name)
}
n = x.Len()
if n < maxLag+2 {
return nil, 0, 0, base.Errf("%s: at least %d samples are needed for lag %d, got %d",
name, maxLag+2, maxLag, n)
}
vals := widenFloats(x)
if err := kfFinite(name, "the series", vals); err != nil {
return nil, 0, 0, err
}
for _, v := range vals {
mean += v
}
mean /= float64(n)
power := 0.0
for _, v := range vals {
d := v - mean
power += d * d
}
variance := power / float64(n)
acf, err := Autocorrelate(x, maxLag)
if err != nil {
return nil, 0, 0, err
}
r = make([]float64, maxLag+1)
for k := range r {
r[k] = acf.FloatAt(k) * variance
}
return r, n, mean, nil
}
// yuleWalker solves the Yule-Walker equations R·φ = r for the
// order-p autoregression's coefficients, R the Toeplitz matrix of the
// autocovariances r[0..p], through the Durbin-Levinson recursion: p²
// work, no matrix factored. It returns φ_1..φ_p and the innovation
// variance r₀·(1 − Σφ_j·ρ_j). A non-positive recursion denominator
// means the autocovariances do not belong to a stationary process;
// the same recursion, run for its reflection coefficients, is what
// PartialAutocorrelate reports.
func yuleWalker(name string, r []float64, p int) (phi []float64, sigma2 float64, err error) {
phi = make([]float64, p+1) // 1-based: phi[k] holds φ_k
prev := make([]float64, p+1)
for k := 1; k <= p; k++ {
num, den := r[k], r[0]
for j := 1; j < k; j++ {
num -= prev[j] * r[k-j]
den -= prev[j] * r[j]
}
if !(den > 0) || math.IsNaN(den) {
return nil, 0, base.Errf("%s: the Durbin-Levinson recursion broke down at order %d: the autocovariances do not describe a stationary process", name, k)
}
phi[k] = num / den
for j := 1; j < k; j++ {
phi[j] = prev[j] - phi[k]*prev[k-j]
}
copy(prev, phi)
}
out := make([]float64, p)
copy(out, phi[1:])
sigma2 = r[0]
for j := 1; j <= p; j++ {
sigma2 -= phi[j] * r[j]
}
if !(sigma2 > 0) || math.IsNaN(sigma2) {
return nil, 0, base.Errf("%s: the innovation variance came out %g: the autocovariances do not describe a stationary process", name, sigma2)
}
return out, sigma2, nil
}
// armaLstsq solves the small least-squares problem min ‖A·c − b‖²
// through the normal equations and Gaussian elimination with partial
// pivoting. The width stays at a model order's size, where the
// squandered conditioning costs nothing that matters; a pivot too
// small against the normal matrix's largest entry reports a
// degenerate regression rather than returning coefficients for it.
func armaLstsq(name string, design [][]float64, target []float64) ([]float64, error) {
width := len(design[0])
g := make([]float64, width*width)
rhs := make([]float64, width)
for i, row := range design {
for a := range width {
rhs[a] += row[a] * target[i]
for b := range width {
g[a*width+b] += row[a] * row[b]
}
}
}
big := 0.0
for _, v := range g {
big = max(big, math.Abs(v))
}
for a := range width {
piv := a
for c := a + 1; c < width; c++ {
if math.Abs(g[c*width+a]) > math.Abs(g[piv*width+a]) {
piv = c
}
}
if !(math.Abs(g[piv*width+a]) > 1e-12*big) {
return nil, base.Errf("%s: the regression design is degenerate: its normal equations have no pivot at column %d", name, a+1)
}
if piv != a {
for b := range width {
g[a*width+b], g[piv*width+b] = g[piv*width+b], g[a*width+b]
}
rhs[a], rhs[piv] = rhs[piv], rhs[a]
}
for c := a + 1; c < width; c++ {
f := g[c*width+a] / g[a*width+a]
for b := a; b < width; b++ {
g[c*width+b] -= f * g[a*width+b]
}
rhs[c] -= f * rhs[a]
}
}
coef := make([]float64, width)
for a := width - 1; a >= 0; a-- {
total := rhs[a]
for b := a + 1; b < width; b++ {
total -= g[a*width+b] * coef[b]
}
coef[a] = total / g[a*width+a]
}
return coef, nil
}
// EstimateAR fits the order-p autoregression x_t − μ = Σ_j
// φ_j·(x_{t−j} − μ) + e_t to the real rank-1 series x: the
// Yule-Walker equations over the biased autocovariance, solved by the
// Durbin-Levinson recursion. The result carries the coefficients, the
// innovation variance, the concentrated Gaussian likelihood of the
// whole series, and the AIC and BIC that compare models fitted to
// one series. An order below 1, a series too short for the lag, and
// non-finite or complex data are errors; autocovariances that do not
// describe a stationary process break the recursion and are reported.
func EstimateAR(x *core.Array, order int) (*ARMAResult, error) {
const name = "EstimateAR"
if order < 1 {
return nil, base.Errf("%s: the order must be at least 1, got %d", name, order)
}
r, n, mean, err := armaAutocovariance(name, x, order)
if err != nil {
return nil, err
}
phi, sigma2, err := yuleWalker(name, r, order)
if err != nil {
return nil, err
}
res := &ARMAResult{AR: phi, InnovationVariance: sigma2, Mean: mean, P: order}
armaLikelihood(res, n)
return res, nil
}
// EstimateARMA fits the ARMA(p, q) model x_t − μ = Σ_j φ_j·(x_{t−j} −
// μ) + e_t + Σ_k θ_k·e_{t−k} by the Hannan-Rissanen innovations
// method: an order-m autoregression first (m from ARMAOptions'
// HighAROrder, defaulting to max(16, p+q+8)), whose residuals stand
// in for the unobserved innovations, then one least-squares
// regression of the demeaned series on its own lags and the lagged
// residuals. The result carries the coefficients in the documented
// convention, the regression residuals' variance as the innovation
// variance and the likelihood and criteria computed from it over the
// whole series, so the criteria line up with the pure AR fit's and
// with the other cells of a SelectARMA grid.
//
// The honest caveat: the method conditions on proxy residuals, so its
// estimates carry more sampling noise than a maximum-likelihood fit
// would, the MA side most of all; tolerances set against it should be
// correspondingly looser. A pure AR model (q = 0) is routed to the
// Yule-Walker fit, which solves that problem exactly; p + q must be
// at least 1, the proxy order at least p+q+1, and the series long
// enough to leave a regression window worth fitting.
func EstimateARMA(x *core.Array, p, q int, opts ARMAOptions) (*ARMAResult, error) {
const name = "EstimateARMA"
if p < 0 || q < 0 {
return nil, base.Errf("%s: the orders must not be negative, got %d and %d", name, p, q)
}
if p+q < 1 {
return nil, base.Errf("%s: at least one coefficient is needed, got orders %d and %d", name, p, q)
}
if q == 0 {
return EstimateAR(x, p)
}
m := opts.HighAROrder
if m == 0 {
m = max(16, p+q+8)
}
if m < p+q+1 {
return nil, base.Errf("%s: the proxy AR order must be at least p+q+1 = %d, got %d", name, p+q+1, m)
}
r, n, mean, err := armaAutocovariance(name, x, m)
if err != nil {
return nil, err
}
rows := n - m - q
if rows < p+q+8 {
return nil, base.Errf("%s: %d samples leave only %d regression rows for %d coefficients; shorten the proxy or lengthen the series",
name, n, rows, p+q)
}
phiAR, _, err := yuleWalker(name, r[:m+1], m)
if err != nil {
return nil, err
}
demeaned := make([]float64, n)
for i := range n {
demeaned[i] = x.FloatAt(i) - mean
}
// The proxy AR's residuals, the innovations' stand-ins: e_t for
// t ≥ m.
resid := make([]float64, n-m)
for t := m; t < n; t++ {
v := demeaned[t]
for j := 1; j <= m; j++ {
v -= phiAR[j-1] * demeaned[t-j]
}
resid[t-m] = v
}
// The regression: x_t − μ on x_{t−1..t−p} − μ and e_{t−1..t−q},
// over the window t = m+q..n−1 the proxies cover.
T := n - m - q
design := make([][]float64, T)
target := make([]float64, T)
for i := range T {
t := m + q + i
row := make([]float64, 0, p+q)
for j := 1; j <= p; j++ {
row = append(row, demeaned[t-j])
}
for k := 1; k <= q; k++ {
row = append(row, resid[t-k-m])
}
design[i] = row
target[i] = demeaned[t]
}
coef, err := armaLstsq(name, design, target)
if err != nil {
return nil, err
}
rss := 0.0
for i := range T {
fitted := 0.0
for a, v := range design[i] {
fitted += v * coef[a]
}
d := target[i] - fitted
rss += d * d
}
sigma2 := rss / float64(T)
if !(sigma2 > 0) || math.IsNaN(sigma2) {
return nil, base.Errf("%s: the innovation variance came out %g: the fit carries no information", name, sigma2)
}
res := &ARMAResult{AR: coef[:p], MA: coef[p:], InnovationVariance: sigma2, Mean: mean, P: p, Q: q}
armaLikelihood(res, n)
return res, nil
}
// SelectARMA searches the order grid 0..maxAR × 0..maxMA for the ARMA
// model the chosen information criterion ranks best: every cell is
// fitted by EstimateARMA, the criterion (ARMAOptions' Criterion,
// "aic" by default) is computed from the innovation variance, and the
// smallest score wins. The (0, 0) cell carries no dynamics and is
// skipped; a cell whose fit fails is skipped too, and a grid where
// nothing can be fitted reports the last failure. The result carries
// both criteria of the winning model, so the runner-up is one more
// sweep away.
//
// The two criteria answer different questions: the AIC's linear
// parameter penalty makes it efficient but willing to overfit by an
// order with probability that does not vanish with the sample count,
// while the BIC's logarithmic penalty makes it consistent, choosing
// the true orders with probability tending to one. Where they
// disagree on long series, the BIC is the one to trust.
func SelectARMA(x *core.Array, maxAR, maxMA int, opts ARMAOptions) (*ARMAResult, error) {
const name = "SelectARMA"
if maxAR < 0 || maxMA < 0 {
return nil, base.Errf("%s: the grid bounds must not be negative, got %d and %d", name, maxAR, maxMA)
}
if maxAR+maxMA < 1 {
return nil, base.Errf("%s: the grid must hold at least one candidate, got %d×%d", name, maxAR+1, maxMA+1)
}
criterion := opts.Criterion
if criterion == "" {
criterion = "aic"
}
if criterion != "aic" && criterion != "bic" {
return nil, base.Errf("%s: the criterion must be %q or %q, got %q", name, "aic", "bic", opts.Criterion)
}
var (
best *ARMAResult
bestScore float64
lastErr error
)
for p := range maxAR + 1 {
for q := range maxMA + 1 {
if p == 0 && q == 0 {
continue
}
res, err := EstimateARMA(x, p, q, opts)
if err != nil {
lastErr = err
continue
}
score := res.AIC
if criterion == "bic" {
score = res.BIC
}
if best == nil || score < bestScore {
best, bestScore = res, score
}
}
}
if best == nil {
return nil, base.Errf("%s: no model on the grid could be fitted: %w", name, lastErr)
}
return best, nil
}
// ARMASpectrum evaluates the fitted model's theoretical power
// spectrum at the nFreq frequencies evenly spaced from 0 to the
// Nyquist frequency 0.5 inclusive, on the one-sided convention the
// house periodogram prints: the bins strictly between DC and Nyquist
// carry the folded double weight, DC and Nyquist the single one. The
// value is σ²·|Θ(e^{−2πif})|² / |Φ(e^{−2πif})|² with that weighting,
// so model and data line up bin for bin against WelchPSD at fs = 1:
// a white-noise sequence of variance σ² estimates σ² at the edges and
// 2σ² between them, and the fitted model's spectrum describes the
// data's own periodogram without a scale fudge. Fewer than two
// frequencies, a nil model, and a non-positive innovation variance or
// non-finite coefficient are errors; a pole of Φ on the unit circle
// names its frequency instead of answering infinities.
func ARMASpectrum(res *ARMAResult, nFreq int) (freqs, psd *core.Array, err error) {
const name = "ARMASpectrum"
if res == nil {
return nil, nil, base.Errf("%s: the model is nil", name)
}
if nFreq < 2 {
return nil, nil, base.Errf("%s: at least two frequencies are needed, got %d", name, nFreq)
}
if !(res.InnovationVariance > 0) || math.IsNaN(res.InnovationVariance) || math.IsInf(res.InnovationVariance, 0) {
return nil, nil, base.Errf("%s: the innovation variance must be positive and finite, got %g",
name, res.InnovationVariance)
}
vals := append(append([]float64{}, res.AR...), res.MA...)
if err := kfFinite(name, "the coefficients", vals); err != nil {
return nil, nil, err
}
freqs = core.New(core.Float, nFreq)
psd = core.New(core.Float, nFreq)
fRow := freqs.RawFloats()
pRow := psd.RawFloats()
for k := range nFreq {
f := 0.5 * float64(k) / float64(nFreq-1)
omega := 2 * math.Pi * f
// |Φ|² and |Θ|² by real arithmetic: the phase parts multiply
// out to squares of sine sums. Both loops read a sine and a
// cosine of the same argument; math.Sincos returns exactly the
// pair math.Sin and math.Cos produce (verified bit-for-bit), so
// the sums are unchanged while the trig work halves.
rePhi, imPhi := 1.0, 0.0
for j, phi := range res.AR {
s, c := math.Sincos(float64(j+1) * omega)
rePhi -= phi * c
imPhi += phi * s
}
reTheta, imTheta := 1.0, 0.0
for k2, theta := range res.MA {
s, c := math.Sincos(float64(k2+1) * omega)
reTheta += theta * c
imTheta -= theta * s
}
den := rePhi*rePhi + imPhi*imPhi
if den == 0 {
return nil, nil, base.Errf("%s: the model has a pole on the unit circle at frequency %g", name, f)
}
fRow[k] = f
pRow[k] = res.InnovationVariance * (reTheta*reTheta + imTheta*imTheta) / den
if k > 0 && k < nFreq-1 {
pRow[k] *= 2 // the one-sided fold between DC and Nyquist
}
}
return freqs, psd, nil
}