Files
tensor/signal/correlate.go
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

251 lines
8.9 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"
)
// 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
}