251 lines
8.9 KiB
Go
251 lines
8.9 KiB
Go
// 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"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Correlation functions: the time-series staples on top of
|
|||
|
|
// the FFT. The autocorrelation and cross-correlation run as spectral
|
|||
|
|
// products (one transform pair whatever the lag count), the partial
|
|||
|
|
// autocorrelation follows by Durbin-Levinson recursion over the ACF,
|
|||
|
|
// the algorithm that defined the PACF in the first place.
|
|||
|
|
|
|||
|
|
// Autocorrelate returns the sample autocorrelation of a rank-1 real
|
|||
|
|
// signal at lags 0..maxLag: the mean is removed first and every value
|
|||
|
|
// is normalised by the lag-zero sum (the biased estimator, the one
|
|||
|
|
// whose Durbin-Levinson and Bartlett-band theory is written for).
|
|||
|
|
// maxLag must lie in [0, n−1]; the result has maxLag+1 entries and
|
|||
|
|
// starts at 1, unless the signal is constant: its zero power makes
|
|||
|
|
// every lag 0, lag zero included.
|
|||
|
|
func Autocorrelate(x *core.Array, maxLag int) (*core.Array, error) {
|
|||
|
|
const name = "Autocorrelate"
|
|||
|
|
if x.NDim() != 1 {
|
|||
|
|
return nil, base.Errf("%s: needs a rank-1 signal, got shape %s", name, base.ShapeText(x.Shape()))
|
|||
|
|
}
|
|||
|
|
if x.Dtype() == core.Complex {
|
|||
|
|
return nil, base.Errf("%s: complex signals are not supported", name)
|
|||
|
|
}
|
|||
|
|
n := x.Len()
|
|||
|
|
if n == 0 {
|
|||
|
|
return nil, base.Errf("%s: an empty signal has no correlation", name)
|
|||
|
|
}
|
|||
|
|
if maxLag < 0 || maxLag >= n {
|
|||
|
|
return nil, base.Errf("%s: maxLag must lie in [0, %d], got %d", name, n-1, maxLag)
|
|||
|
|
}
|
|||
|
|
// Demean. The payload walk reads the same values FloatAt would
|
|||
|
|
// (widenFloats widens exactly), in the same order, so the mean
|
|||
|
|
// and every padded sample keep their bits.
|
|||
|
|
xF := x.RawFloats()
|
|||
|
|
if xF == nil || x.Strided() {
|
|||
|
|
xF = widenFloats(x)
|
|||
|
|
}
|
|||
|
|
// A non-finite sample would drive the normaliser NaN and publish
|
|||
|
|
// NaN correlations with no error, so it is refused up front (the
|
|||
|
|
// guard SolvePoissonPeriodic applies to its source).
|
|||
|
|
for i := range n {
|
|||
|
|
if v := xF[i]; math.IsNaN(v) || math.IsInf(v, 0) {
|
|||
|
|
return nil, base.Errf("%s: x holds the non-finite value %g at %d", name, v, i)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
mean := 0.0
|
|||
|
|
for i := range n {
|
|||
|
|
mean += xF[i]
|
|||
|
|
}
|
|||
|
|
mean /= float64(n)
|
|||
|
|
// The correlation runs as one transform pair over the zero-padded
|
|||
|
|
// signal, in place on a private buffer: the values that reach the
|
|||
|
|
// butterflies and the arithmetic that follows are the FFT/IFFT
|
|||
|
|
// path's unchanged, minus the copies into and out of arrays that
|
|||
|
|
// nobody else ever read.
|
|||
|
|
padded := make([]complex128, 2*n)
|
|||
|
|
for i := range n {
|
|||
|
|
padded[i] = complex(xF[i]-mean, 0)
|
|||
|
|
}
|
|||
|
|
transform(padded, -1)
|
|||
|
|
// |F|²: multiply by the conjugate, then back. The zero-padded
|
|||
|
|
// transform makes the circular correlation linear.
|
|||
|
|
for i := range padded {
|
|||
|
|
padded[i] = padded[i] * conj128(padded[i])
|
|||
|
|
}
|
|||
|
|
transform(padded, +1)
|
|||
|
|
// The inverse scaling, applied with IFFT's own operation order.
|
|||
|
|
m := float64(2 * n)
|
|||
|
|
for i := range padded {
|
|||
|
|
padded[i] /= complex(m, 0)
|
|||
|
|
}
|
|||
|
|
den := real(padded[0])
|
|||
|
|
out := make([]float64, maxLag+1)
|
|||
|
|
for k := range maxLag + 1 {
|
|||
|
|
if den == 0 {
|
|||
|
|
out[k] = 0
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
out[k] = real(padded[k]) / den
|
|||
|
|
}
|
|||
|
|
return floatsFromArrayMust(out, []int{maxLag + 1}), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// conj128 conjugates one complex number (the local spelling keeps the
|
|||
|
|
// dependency surface to the packages already imported).
|
|||
|
|
func conj128(z complex128) complex128 { return complex(real(z), -imag(z)) }
|
|||
|
|
|
|||
|
|
// CrossCorrelate returns the raw cross-correlation of two equal-length
|
|||
|
|
// real signals at every lag: the result has 2n−1 entries, index i
|
|||
|
|
// carrying lag i−(n−1), value r(lag) = Σ_t x[t+lag]·y[t] over the
|
|||
|
|
// overlapping t. No mean removal, no normalisation: correlation as
|
|||
|
|
// the inner product of shifted copies, the definition convolution and
|
|||
|
|
// matched filtering share.
|
|||
|
|
func CrossCorrelate(x, y *core.Array) (*core.Array, error) {
|
|||
|
|
const name = "CrossCorrelate"
|
|||
|
|
if x.NDim() != 1 || y.NDim() != 1 {
|
|||
|
|
return nil, base.Errf("%s: needs two rank-1 signals, got %s and %s",
|
|||
|
|
name, base.ShapeText(x.Shape()), base.ShapeText(y.Shape()))
|
|||
|
|
}
|
|||
|
|
if x.Dtype() == core.Complex || y.Dtype() == core.Complex {
|
|||
|
|
return nil, base.Errf("%s: complex signals are not supported", name)
|
|||
|
|
}
|
|||
|
|
n := x.Len()
|
|||
|
|
if n == 0 || y.Len() != n {
|
|||
|
|
return nil, base.Errf("%s: the signals must be equal-length non-empty, got %d and %d",
|
|||
|
|
name, n, y.Len())
|
|||
|
|
}
|
|||
|
|
// Zero-pad both signals through the raw payloads where possible;
|
|||
|
|
// the widened reads equal the FloatAt values bit for bit.
|
|||
|
|
xF := x.RawFloats()
|
|||
|
|
if xF == nil || x.Strided() {
|
|||
|
|
xF = widenFloats(x)
|
|||
|
|
}
|
|||
|
|
yF := y.RawFloats()
|
|||
|
|
if yF == nil || y.Strided() {
|
|||
|
|
yF = widenFloats(y)
|
|||
|
|
}
|
|||
|
|
// A non-finite sample would publish NaN correlations with no
|
|||
|
|
// error, so both signals are refused up front.
|
|||
|
|
for i := range n {
|
|||
|
|
if v := xF[i]; math.IsNaN(v) || math.IsInf(v, 0) {
|
|||
|
|
return nil, base.Errf("%s: x holds the non-finite value %g at %d", name, v, i)
|
|||
|
|
}
|
|||
|
|
if v := yF[i]; math.IsNaN(v) || math.IsInf(v, 0) {
|
|||
|
|
return nil, base.Errf("%s: y holds the non-finite value %g at %d", name, v, i)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// The spectra multiply in place on private buffers: the values
|
|||
|
|
// reaching the transforms and the per-element products are the
|
|||
|
|
// FFT/mulSpectraConj/IFFT path's unchanged, minus the copies into
|
|||
|
|
// and out of arrays nobody else ever read.
|
|||
|
|
px := make([]complex128, 2*n)
|
|||
|
|
py := make([]complex128, 2*n)
|
|||
|
|
for i := range n {
|
|||
|
|
px[i] = complex(xF[i], 0)
|
|||
|
|
py[i] = complex(yF[i], 0)
|
|||
|
|
}
|
|||
|
|
transform(px, -1)
|
|||
|
|
transform(py, -1)
|
|||
|
|
// Fx·conj(Fy) transforms back to Σ_t x[t+lag]·y[t], the documented
|
|||
|
|
// orientation; the index mapping lands the positive lags in the
|
|||
|
|
// first half.
|
|||
|
|
// The product runs in mulSpectraConj, the array-based helper the
|
|||
|
|
// FFT path always used: the compiler's FMA fusion of the complex
|
|||
|
|
// multiply depends on the compiled shape of its surroundings, and
|
|||
|
|
// the helper's shape is the one whose product bits this result is
|
|||
|
|
// pinned to. The wrappers take the buffers without copying; the
|
|||
|
|
// product array is the one allocation of the old path this keeps.
|
|||
|
|
prod, perr := mulSpectraConj(complexFromArrayMust(px, []int{2 * n}),
|
|||
|
|
complexFromArrayMust(py, []int{2 * n}))
|
|||
|
|
if perr != nil {
|
|||
|
|
return nil, base.Errf("%s: %w", name, perr)
|
|||
|
|
}
|
|||
|
|
pc := prod.RawComplexes()
|
|||
|
|
transform(pc, +1)
|
|||
|
|
m := float64(2 * n)
|
|||
|
|
for i := range pc {
|
|||
|
|
pc[i] /= complex(m, 0)
|
|||
|
|
}
|
|||
|
|
// Lag 0 sits at index 0 of the transformed product, lag k at k and
|
|||
|
|
// negative lag −k at 2n−k; the output centres them on index n−1.
|
|||
|
|
out := make([]float64, 2*n-1)
|
|||
|
|
for k := range 2*n - 1 {
|
|||
|
|
lag := k - (n - 1)
|
|||
|
|
if lag >= 0 {
|
|||
|
|
out[k] = real(pc[lag])
|
|||
|
|
} else {
|
|||
|
|
out[k] = real(pc[2*n+lag])
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return floatsFromArrayMust(out, []int{2*n - 1}), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// mulSpectraConj multiplies the first spectrum by the conjugate of the
|
|||
|
|
// second, element-wise. Both spectra come from the FFT and stream
|
|||
|
|
// their complex payloads directly; the accessor loop stays as the
|
|||
|
|
// fallback for anything else. The function is kept byte-for-byte as
|
|||
|
|
// the FFT path always defined it: its compiled FMA pattern is what the
|
|||
|
|
// correlation results are pinned to.
|
|||
|
|
func mulSpectraConj(a, b *core.Array) (*core.Array, error) {
|
|||
|
|
as := a.RawComplexes()
|
|||
|
|
bs := b.RawComplexes()
|
|||
|
|
if as != nil && bs != nil && !a.Strided() && !b.Strided() {
|
|||
|
|
out := make([]complex128, len(as))
|
|||
|
|
for i := range out {
|
|||
|
|
out[i] = as[i] * conj128(bs[i])
|
|||
|
|
}
|
|||
|
|
return core.ComplexFromArray(out, a.Shape()...)
|
|||
|
|
}
|
|||
|
|
out := make([]complex128, a.Len())
|
|||
|
|
for i := range out {
|
|||
|
|
out[i] = a.ComplexAt(i) * conj128(b.ComplexAt(i))
|
|||
|
|
}
|
|||
|
|
return core.ComplexFromArray(out, a.Shape()...)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// PartialAutocorrelate returns the partial autocorrelation of a rank-1
|
|||
|
|
// real signal at lags 1..maxLag, by the Durbin-Levinson recursion over
|
|||
|
|
// the biased autocorrelation: pacf[k] is the last coefficient of the
|
|||
|
|
// order-k AR fit, the "correlation with the intermediate lags
|
|||
|
|
// regressed out" that box-jenkins identification reads. maxLag must
|
|||
|
|
// lie in [1, n−2] for the recursion to stay meaningful.
|
|||
|
|
func PartialAutocorrelate(x *core.Array, maxLag int) (*core.Array, error) {
|
|||
|
|
const name = "PartialAutocorrelate"
|
|||
|
|
n := x.Len()
|
|||
|
|
if maxLag < 1 || maxLag > n-2 {
|
|||
|
|
return nil, base.Errf("%s: maxLag must lie in [1, %d], got %d", name, n-2, maxLag)
|
|||
|
|
}
|
|||
|
|
acf, err := Autocorrelate(x, maxLag)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
// r[k] = acf at lag k (1-based recursion indexing).
|
|||
|
|
r := make([]float64, maxLag+1)
|
|||
|
|
r[0] = 1
|
|||
|
|
for k := range maxLag {
|
|||
|
|
r[k+1] = acf.FloatAt(k + 1)
|
|||
|
|
}
|
|||
|
|
phi := make([]float64, maxLag+1) // current order's coefficients
|
|||
|
|
phiPrev := make([]float64, maxLag+1)
|
|||
|
|
out := make([]float64, maxLag)
|
|||
|
|
for k := 1; k <= maxLag; k++ {
|
|||
|
|
num := r[k]
|
|||
|
|
den := 1.0
|
|||
|
|
for j := 1; j < k; j++ {
|
|||
|
|
num -= phiPrev[j] * r[k-j]
|
|||
|
|
den -= phiPrev[j] * r[j]
|
|||
|
|
}
|
|||
|
|
if den == 0 {
|
|||
|
|
return nil, base.Errf("%s: the recursion broke down at lag %d", name, k)
|
|||
|
|
}
|
|||
|
|
phi[k] = num / den
|
|||
|
|
for j := 1; j < k; j++ {
|
|||
|
|
phi[j] = phiPrev[j] - phi[k]*phiPrev[k-j]
|
|||
|
|
}
|
|||
|
|
copy(phiPrev, phi)
|
|||
|
|
out[k-1] = phi[k]
|
|||
|
|
}
|
|||
|
|
return floatsFromArrayMust(out, []int{maxLag}), nil
|
|||
|
|
}
|