// Copyright (c) 2026 Petr Balvín (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 }