Files
tensor/signal/arma.go
T

477 lines
17 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 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
}