942 lines
32 KiB
Go
942 lines
32 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package signal
|
||
|
||
import (
|
||
"math"
|
||
"slices"
|
||
|
||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||
)
|
||
|
||
// State-space filtering: the Kalman family over the model
|
||
// x_{t+1} = f(x_t) + w_t with w ~ N(0, Q), z_t = h(x_t) + v_t with
|
||
// v ~ N(0, R). The linear filter runs the standard Riccati recursion
|
||
// with a Joseph-form correction, the extended filter linearises f and
|
||
// h at the current estimate (analytic Jacobians when supplied, the
|
||
// house central-difference helper otherwise), and the unscented filter
|
||
// carries the state distribution through the deterministic sigma-point
|
||
// set. All three accumulate the exact Gaussian log-likelihood of the
|
||
// innovation sequence, and every covariance they publish is mirrored
|
||
// into its symmetric average, with positive definiteness enforced
|
||
// where the mathematics demands it: a Cholesky factorisation that
|
||
// cannot be taken names the step it failed at.
|
||
|
||
// StateFunc maps one state vector to the image vector the model's
|
||
// transition or observation applies: the f of x_{t+1} = f(x_t) + w_t,
|
||
// the h of z_t = h(x_t) + v_t. The input array is private to the call
|
||
// and may be kept until the call returns; the output must be a real
|
||
// rank-1 array of one fixed length.
|
||
type StateFunc func(x *core.Array) (*core.Array, error)
|
||
|
||
// JacobianFunc returns the Jacobian of a StateFunc at x as an
|
||
// (m × n) float array, row i holding the partials of output i. An
|
||
// analytic Jacobian is always the better instrument; when the option
|
||
// is left unset the extended filter builds one by central differences
|
||
// at every step.
|
||
type JacobianFunc func(x *core.Array) (*core.Array, error)
|
||
|
||
// KalmanOptions carries the initial condition, the noise levels and
|
||
// the filter-specific knobs. An unset (nil) array field takes its
|
||
// documented default: a zero initial state, an identity initial
|
||
// covariance, zero process noise, an identity measurement noise.
|
||
// SigmaAlpha, SigmaBeta and SigmaKappa follow the same rule with the
|
||
// defaults 0.001, 2 and 0, the standard scaled unscented choice for a
|
||
// Gaussian prior. The extended and unscented filters cannot infer the
|
||
// state dimension from their callbacks, so at least one of
|
||
// InitialState and InitialCovariance must be set for them.
|
||
//
|
||
// TransitionJacobian and ObservationJacobian give the extended filter
|
||
// the analytic partials it linearises with; a nil one is replaced by
|
||
// central differences on the underlying map, two evaluations per
|
||
// partial per step.
|
||
type KalmanOptions struct {
|
||
InitialState *core.Array
|
||
InitialCovariance *core.Array
|
||
ProcessNoise *core.Array
|
||
MeasurementNoise *core.Array
|
||
TransitionJacobian JacobianFunc
|
||
ObservationJacobian JacobianFunc
|
||
SigmaAlpha float64
|
||
SigmaBeta float64
|
||
SigmaKappa float64
|
||
}
|
||
|
||
// KalmanResult holds one filtering pass over the measurement stack.
|
||
type KalmanResult struct {
|
||
// States is the (n × d) stack of filtered means x̂_{t|t}, one row
|
||
// per measurement.
|
||
States *core.Array
|
||
// Covariances is the (n × d × d) stack of filtered covariances
|
||
// P_{t|t}, one symmetric positive-definite block per measurement.
|
||
Covariances *core.Array
|
||
// Innovations is the (n × m) stack of one-step prediction errors
|
||
// z_t − h(x̂_{t|t−1}).
|
||
Innovations *core.Array
|
||
// InnovationCovariances is the (n × m × m) stack of the innovation
|
||
// covariances S_t the likelihood reads.
|
||
InnovationCovariances *core.Array
|
||
// LogLikelihood is Σ_t log N(z_t; h(x̂_{t|t−1}), S_t), the exact
|
||
// Gaussian likelihood of the measurement sequence under the model
|
||
// and the quantity noise and parameter estimation maximises.
|
||
LogLikelihood float64
|
||
}
|
||
|
||
// kalmanStepOut carries one step's outputs from a filter's step
|
||
// closure into the result stack.
|
||
type kalmanStepOut struct {
|
||
mean []float64
|
||
covariance []float64
|
||
innovation []float64
|
||
innovationCov []float64
|
||
logLikelihood float64
|
||
}
|
||
|
||
// kfSymmetryEps is the relative tolerance a covariance's mirror check
|
||
// allows, the same reading the multivariate normal takes: matrices
|
||
// assembled from products differ from their mirror by an ulp of
|
||
// rounding, a genuinely asymmetric pair by far more.
|
||
const kfSymmetryEps = 1e-12
|
||
|
||
// kfFinite refuses the non-finite values a filter would otherwise
|
||
// carry silently through every recursion.
|
||
func kfFinite(name, what string, vals []float64) error {
|
||
for i, v := range vals {
|
||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||
return base.Errf("%s: %s holds the non-finite value %g at %d", name, what, v, i)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// kfVector reads a finite real rank-1 array into a float slice, the
|
||
// payload itself for a contiguous float64 array,
|
||
// with copies made only where the payload cannot serve, of exactly want entries when want is non-negative.
|
||
func kfVector(name, what string, a *core.Array, want int) ([]float64, error) {
|
||
if a.NDim() != 1 {
|
||
return nil, base.Errf("%s: %s must be a vector, got shape %s", name, what, base.ShapeText(a.Shape()))
|
||
}
|
||
if a.Dtype() == core.Complex {
|
||
return nil, base.Errf("%s: %s must be real, got complex", name, what)
|
||
}
|
||
if want >= 0 && a.Len() != want {
|
||
return nil, base.Errf("%s: %s holds %d entries, want %d", name, what, a.Len(), want)
|
||
}
|
||
vals := widenFloats(a)
|
||
if err := kfFinite(name, what, vals); err != nil {
|
||
return nil, err
|
||
}
|
||
return vals, nil
|
||
}
|
||
|
||
// kfMatrix reads a finite real rank-2 array into a fresh row-major
|
||
// float slice of exactly rows×cols, either side wildcarded at −1.
|
||
func kfMatrix(name, what string, a *core.Array, rows, cols int) ([]float64, error) {
|
||
if a.NDim() != 2 {
|
||
return nil, base.Errf("%s: %s must be rank 2, got shape %s", name, what, base.ShapeText(a.Shape()))
|
||
}
|
||
if a.Dtype() == core.Complex {
|
||
return nil, base.Errf("%s: %s must be real, got complex", name, what)
|
||
}
|
||
shape := a.Shape()
|
||
if (rows >= 0 && shape[0] != rows) || (cols >= 0 && shape[1] != cols) {
|
||
return nil, base.Errf("%s: %s has shape %s, want %d×%d", name, what, base.ShapeText(shape), rows, cols)
|
||
}
|
||
vals := widenFloats(a)
|
||
if err := kfFinite(name, what, vals); err != nil {
|
||
return nil, err
|
||
}
|
||
return vals, nil
|
||
}
|
||
|
||
// kfSymmetric demands every mirror pair agree within a relative
|
||
// tolerance, because only one triangle is ever read.
|
||
func kfSymmetric(name, what string, a []float64, n int) error {
|
||
for i := range n {
|
||
for j := range i {
|
||
lo, hi := a[i*n+j], a[j*n+i]
|
||
if math.Abs(lo-hi) > kfSymmetryEps*math.Max(math.Abs(lo), math.Abs(hi)) {
|
||
return base.Errf("%s: %s is not symmetric at (%d, %d): %g against %g",
|
||
name, what, i+1, j+1, lo, hi)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// kfNoise reads one noise covariance: an unset array takes its
|
||
// documented default (zeros for the process noise, whose only job is
|
||
// to enter sums, an identity for the measurement noise, which is
|
||
// factored every step and so must be positive definite), a set one
|
||
// must be a finite symmetric matrix of the right size.
|
||
func kfNoise(name, what string, a *core.Array, dim int, identityDefault bool) ([]float64, error) {
|
||
if a == nil {
|
||
if identityDefault {
|
||
return kfIdentity(dim), nil
|
||
}
|
||
return make([]float64, dim*dim), nil
|
||
}
|
||
vals, err := kfMatrix(name, what, a, dim, dim)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := kfSymmetric(name, what, vals, dim); err != nil {
|
||
return nil, err
|
||
}
|
||
if identityDefault {
|
||
if _, err := kfCholesky(what, vals, dim); err != nil {
|
||
return nil, base.Errf("%s: %w", name, err)
|
||
}
|
||
}
|
||
return vals, nil
|
||
}
|
||
|
||
// kfInitialCondition reads the initial mean and covariance, zeros and
|
||
// identity where the options leave them unset. The initial covariance
|
||
// must be symmetric positive definite: the first predict would
|
||
// otherwise hand the recursion a structure it cannot factor.
|
||
func kfInitialCondition(name string, opts KalmanOptions, d int) (x0, p0 []float64, err error) {
|
||
if opts.InitialState == nil {
|
||
x0 = make([]float64, d)
|
||
} else if x0, err = kfVector(name, "the initial state", opts.InitialState, d); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if opts.InitialCovariance == nil {
|
||
p0 = kfIdentity(d)
|
||
} else {
|
||
if p0, err = kfMatrix(name, "the initial covariance", opts.InitialCovariance, d, d); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if err := kfSymmetric(name, "the initial covariance", p0, d); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if _, err := kfCholesky("the initial covariance", p0, d); err != nil {
|
||
return nil, nil, base.Errf("%s: %w", name, err)
|
||
}
|
||
}
|
||
return x0, p0, nil
|
||
}
|
||
|
||
// kfNonlinearInputs resolves the shared inputs of the extended and
|
||
// unscented filters: the state dimension, which the callbacks cannot
|
||
// carry, comes from the initial state or the initial covariance, at
|
||
// least one of which must be set.
|
||
func kfNonlinearInputs(name string, opts KalmanOptions, m int) (d int, x0, p0, q, r []float64, err error) {
|
||
switch {
|
||
case opts.InitialState != nil:
|
||
if x0, err = kfVector(name, "the initial state", opts.InitialState, -1); err != nil {
|
||
return 0, nil, nil, nil, nil, err
|
||
}
|
||
d = len(x0)
|
||
case opts.InitialCovariance != nil:
|
||
if opts.InitialCovariance.NDim() != 2 {
|
||
return 0, nil, nil, nil, nil, base.Errf("%s: the initial covariance must be rank 2, got shape %s",
|
||
name, base.ShapeText(opts.InitialCovariance.Shape()))
|
||
}
|
||
d = opts.InitialCovariance.Shape()[0]
|
||
default:
|
||
return 0, nil, nil, nil, nil, base.Errf("%s: the state dimension must come from an initial state or an initial covariance; neither is set", name)
|
||
}
|
||
if d < 1 {
|
||
return 0, nil, nil, nil, nil, base.Errf("%s: the state dimension must be at least 1, got %d", name, d)
|
||
}
|
||
if x0, p0, err = kfInitialCondition(name, opts, d); err != nil {
|
||
return 0, nil, nil, nil, nil, err
|
||
}
|
||
if q, err = kfNoise(name, "the process noise", opts.ProcessNoise, d, false); err != nil {
|
||
return 0, nil, nil, nil, nil, err
|
||
}
|
||
if r, err = kfNoise(name, "the measurement noise", opts.MeasurementNoise, m, true); err != nil {
|
||
return 0, nil, nil, nil, nil, err
|
||
}
|
||
return d, x0, p0, q, r, nil
|
||
}
|
||
|
||
// kfMeasurements reads the measurement stack: a rank-1 array holds n
|
||
// scalar observations, a rank-2 array holds n rows of m channels. At
|
||
// least one measurement is needed, every value must be finite.
|
||
func kfMeasurements(name string, z *core.Array) (n, m int, rows []float64, err error) {
|
||
if z.NDim() != 1 && z.NDim() != 2 {
|
||
return 0, 0, nil, base.Errf("%s: the measurements must be rank 1 or rank 2, got shape %s",
|
||
name, base.ShapeText(z.Shape()))
|
||
}
|
||
if z.Dtype() == core.Complex {
|
||
return 0, 0, nil, base.Errf("%s: complex measurements are not supported", name)
|
||
}
|
||
if z.NDim() == 1 {
|
||
m = 1
|
||
} else {
|
||
m = z.Shape()[1]
|
||
}
|
||
if m < 1 {
|
||
return 0, 0, nil, base.Errf("%s: the measurement width must be at least 1, got %d", name, m)
|
||
}
|
||
n = z.Len() / m
|
||
if n < 1 {
|
||
return 0, 0, nil, base.Errf("%s: at least one measurement is needed", name)
|
||
}
|
||
rows = widenFloats(z)
|
||
if err := kfFinite(name, "the measurements", rows); err != nil {
|
||
return 0, 0, nil, err
|
||
}
|
||
return n, m, rows, nil
|
||
}
|
||
|
||
// kfIdentity returns the n×n identity, row-major.
|
||
func kfIdentity(n int) []float64 {
|
||
a := make([]float64, n*n)
|
||
for i := range n {
|
||
a[i*n+i] = 1
|
||
}
|
||
return a
|
||
}
|
||
|
||
// kfTranspose returns the transpose of the row-major rows×cols
|
||
// matrix.
|
||
func kfTranspose(a []float64, rows, cols int) []float64 {
|
||
out := make([]float64, rows*cols)
|
||
for i := range rows {
|
||
for j := range cols {
|
||
out[j*rows+i] = a[i*cols+j]
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// kfMatVec returns A·x for the row-major rows×cols matrix A.
|
||
func kfMatVec(a []float64, rows, cols int, x []float64) []float64 {
|
||
out := make([]float64, rows)
|
||
for i := range rows {
|
||
total := 0.0
|
||
row := a[i*cols : (i+1)*cols]
|
||
for j, v := range row {
|
||
total += v * x[j]
|
||
}
|
||
out[i] = total
|
||
}
|
||
return out
|
||
}
|
||
|
||
// kfMatMul returns A·B for the row-major A of size ra×ca and B of
|
||
// size ca×cb.
|
||
func kfMatMul(a []float64, ra, ca int, b []float64, cb int) []float64 {
|
||
out := make([]float64, ra*cb)
|
||
for i := range ra {
|
||
row := out[i*cb : (i+1)*cb]
|
||
arow := a[i*ca : (i+1)*ca]
|
||
for k, aik := range arow {
|
||
brow := b[k*cb : (k+1)*cb]
|
||
for j := range cb {
|
||
row[j] += aik * brow[j]
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// kfCholesky factors the symmetric positive-definite row-major n×n
|
||
// matrix into the lower triangular L with A = L·Lᵀ, only the lower
|
||
// mirror read. A non-positive pivot names its row.
|
||
func kfCholesky(what string, a []float64, n int) ([]float64, error) {
|
||
l := make([]float64, n*n)
|
||
for i := range n {
|
||
for j := range i + 1 {
|
||
total := a[i*n+j]
|
||
for k := range j {
|
||
total -= l[i*n+k] * l[j*n+k]
|
||
}
|
||
if i == j {
|
||
if !(total > 0) {
|
||
return nil, base.Errf("%s is not positive definite at row %d", what, i+1)
|
||
}
|
||
l[i*n+i] = math.Sqrt(total)
|
||
} else {
|
||
l[i*n+j] = total / l[j*n+j]
|
||
}
|
||
}
|
||
}
|
||
return l, nil
|
||
}
|
||
|
||
// kfCholSolve solves L·Lᵀ·x = b through the forward and backward
|
||
// substitutions.
|
||
func kfCholSolve(l []float64, n int, b []float64) []float64 {
|
||
x := make([]float64, n)
|
||
for i := range n {
|
||
total := b[i]
|
||
for k := range i {
|
||
total -= l[i*n+k] * x[k]
|
||
}
|
||
x[i] = total / l[i*n+i]
|
||
}
|
||
for i := n - 1; i >= 0; i-- {
|
||
total := x[i]
|
||
for k := i + 1; k < n; k++ {
|
||
total -= l[k*n+i] * x[k]
|
||
}
|
||
x[i] = total / l[i*n+i]
|
||
}
|
||
return x
|
||
}
|
||
|
||
// kfCholSolveMatrix solves L·Lᵀ·X = B for the row-major B of size
|
||
// n×cols, row by row.
|
||
func kfCholSolveMatrix(l []float64, n int, b []float64, cols int) []float64 {
|
||
x := make([]float64, len(b))
|
||
copy(x, b)
|
||
for i := range n {
|
||
row := x[i*cols : (i+1)*cols]
|
||
for k := range i {
|
||
lk := l[i*n+k]
|
||
krow := x[k*cols : (k+1)*cols]
|
||
for j := range cols {
|
||
row[j] -= lk * krow[j]
|
||
}
|
||
}
|
||
di := l[i*n+i]
|
||
for j := range cols {
|
||
row[j] /= di
|
||
}
|
||
}
|
||
for i := n - 1; i >= 0; i-- {
|
||
row := x[i*cols : (i+1)*cols]
|
||
for k := i + 1; k < n; k++ {
|
||
lk := l[k*n+i]
|
||
krow := x[k*cols : (k+1)*cols]
|
||
for j := range cols {
|
||
row[j] -= lk * krow[j]
|
||
}
|
||
}
|
||
di := l[i*n+i]
|
||
for j := range cols {
|
||
row[j] /= di
|
||
}
|
||
}
|
||
return x
|
||
}
|
||
|
||
// kfLogDet returns the log determinant of the Cholesky factor: twice
|
||
// the sum of the log diagonal.
|
||
func kfLogDet(l []float64, n int) float64 {
|
||
total := 0.0
|
||
for i := range n {
|
||
total += math.Log(l[i*n+i])
|
||
}
|
||
return 2 * total
|
||
}
|
||
|
||
// kfSymmetrise replaces A by (A + Aᵀ)/2 in place: the rounding drift
|
||
// of a recursion that touches a covariance only through symmetric
|
||
// expressions cannot survive the mirror average.
|
||
func kfSymmetrise(a []float64, n int) {
|
||
for i := range n {
|
||
for j := range i {
|
||
avg := (a[i*n+j] + a[j*n+i]) / 2
|
||
a[i*n+j] = avg
|
||
a[j*n+i] = avg
|
||
}
|
||
}
|
||
}
|
||
|
||
// kalmanRun walks the measurement stack through the step closure,
|
||
// which owns one predict-and-correct cycle: it receives the current
|
||
// mean and covariance (read-only; it must return fresh slices) and
|
||
// the step's measurement row, and returns everything the result stack
|
||
// records, the Gaussian log-likelihood contribution included.
|
||
func kalmanRun(nMeas, m, d int, meas []float64, x0, p0 []float64,
|
||
step func(t int, zRow, mean, cov []float64) (kalmanStepOut, error),
|
||
) (*KalmanResult, error) {
|
||
out := &KalmanResult{
|
||
States: core.New(core.Float, nMeas, d),
|
||
Covariances: core.New(core.Float, nMeas, d, d),
|
||
Innovations: core.New(core.Float, nMeas, m),
|
||
InnovationCovariances: core.New(core.Float, nMeas, m, m),
|
||
}
|
||
mean := slices.Clone(x0)
|
||
cov := slices.Clone(p0)
|
||
total := 0.0
|
||
for t := range nMeas {
|
||
res, err := step(t, meas[t*m:(t+1)*m], mean, cov)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
copy(out.States.RawFloats()[t*d:(t+1)*d], res.mean)
|
||
copy(out.Covariances.RawFloats()[t*d*d:(t+1)*d*d], res.covariance)
|
||
copy(out.Innovations.RawFloats()[t*m:(t+1)*m], res.innovation)
|
||
copy(out.InnovationCovariances.RawFloats()[t*m*m:(t+1)*m*m], res.innovationCov)
|
||
total += res.logLikelihood
|
||
mean, cov = res.mean, res.covariance
|
||
}
|
||
out.LogLikelihood = total
|
||
return out, nil
|
||
}
|
||
|
||
// kfCorrect runs the shared correction of the linear and extended
|
||
// filters: the innovation against the predicted observation, its
|
||
// covariance S = H·P⁻·Hᵀ + R, the gain K = P⁻·Hᵀ·S⁻¹ through the
|
||
// Cholesky solve, the state update and the Joseph-form covariance
|
||
// (I−K·H)·P⁻·(I−K·H)ᵀ + K·R·Kᵀ, mirrored into its symmetric average.
|
||
// The Joseph form keeps the filtered covariance symmetric positive
|
||
// definite by construction, where the plain (I−K·H)·P⁻ recursion
|
||
// preserves it only in exact arithmetic.
|
||
func kfCorrect(name string, t int, zRow, predicted, xp, pp []float64, d, m int,
|
||
h, ht, r []float64,
|
||
) (kalmanStepOut, error) {
|
||
innovation := make([]float64, m)
|
||
for i := range m {
|
||
innovation[i] = zRow[i] - predicted[i]
|
||
}
|
||
hp := kfMatMul(h, m, d, pp, d)
|
||
s := kfMatMul(hp, m, d, ht, m)
|
||
for i := range s {
|
||
s[i] += r[i]
|
||
}
|
||
ls, err := kfCholesky("the innovation covariance", s, m)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t, err)
|
||
}
|
||
solved := kfCholSolve(ls, m, innovation)
|
||
quad := 0.0
|
||
for i := range m {
|
||
quad += innovation[i] * solved[i]
|
||
}
|
||
// K = P⁻·Hᵀ·S⁻¹ arrives through the transposed solve: S·X =
|
||
// H·P⁻ gives X = S⁻¹·H·P⁻ = Kᵀ and K = Xᵀ.
|
||
gain := kfTranspose(kfCholSolveMatrix(ls, m, hp, d), m, d)
|
||
mean := make([]float64, d)
|
||
copy(mean, xp)
|
||
for i := range d {
|
||
total := 0.0
|
||
grow := gain[i*m : (i+1)*m]
|
||
for j, y := range innovation {
|
||
total += grow[j] * y
|
||
}
|
||
mean[i] += total
|
||
}
|
||
kh := kfMatMul(gain, d, m, h, d)
|
||
a := make([]float64, d*d)
|
||
for i := range d {
|
||
for j := range d {
|
||
a[i*d+j] = -kh[i*d+j]
|
||
}
|
||
a[i*d+i]++
|
||
}
|
||
at := kfTranspose(a, d, d)
|
||
cov := kfMatMul(kfMatMul(a, d, d, pp, d), d, d, at, d)
|
||
krkt := kfMatMul(kfMatMul(gain, d, m, r, m), d, m, kfTranspose(gain, d, m), d)
|
||
for i := range cov {
|
||
cov[i] += krkt[i]
|
||
}
|
||
kfSymmetrise(cov, d)
|
||
logLik := -0.5 * (float64(m)*math.Log(2*math.Pi) + kfLogDet(ls, m) + quad)
|
||
return kalmanStepOut{mean: mean, covariance: cov, innovation: innovation,
|
||
innovationCov: s, logLikelihood: logLik}, nil
|
||
}
|
||
|
||
// KalmanFilter runs the linear Kalman filter over the measurement
|
||
// stack z under the model x_{t+1} = F·x_t + w_t, z_t = H·x_t + v_t:
|
||
// transition is the (d × d) state matrix F, observation the (m × d)
|
||
// matrix H. Every step predicts with F and corrects with a
|
||
// Joseph-form update, and the exact Gaussian log-likelihood of the
|
||
// innovation sequence accumulates into the result.
|
||
//
|
||
// A rank-1 z holds n scalar observations, a rank-2 z holds n rows of
|
||
// m channels. Nil option fields take their defaults (see
|
||
// KalmanOptions). A non-square transition, a shape mismatch, an
|
||
// asymmetric noise covariance, a singular measurement noise, a
|
||
// non-finite value anywhere, and an innovation covariance that loses
|
||
// positive definiteness (naming the step) are errors.
|
||
func KalmanFilter(z *core.Array, transition, observation *core.Array, opts KalmanOptions) (*KalmanResult, error) {
|
||
const name = "KalmanFilter"
|
||
if transition == nil || observation == nil {
|
||
return nil, base.Errf("%s: the transition and observation matrices are required", name)
|
||
}
|
||
if transition.NDim() != 2 {
|
||
return nil, base.Errf("%s: the transition matrix must be rank 2, got shape %s",
|
||
name, base.ShapeText(transition.Shape()))
|
||
}
|
||
shape := transition.Shape()
|
||
if shape[0] != shape[1] {
|
||
return nil, base.Errf("%s: the transition matrix must be square, got %d×%d", name, shape[0], shape[1])
|
||
}
|
||
d := shape[0]
|
||
if d < 1 {
|
||
return nil, base.Errf("%s: the state dimension must be at least 1, got %d", name, d)
|
||
}
|
||
nMeas, m, meas, err := kfMeasurements(name, z)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
f, err := kfMatrix(name, "the transition matrix", transition, d, d)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
h, err := kfMatrix(name, "the observation matrix", observation, m, d)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
q, err := kfNoise(name, "the process noise", opts.ProcessNoise, d, false)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
r, err := kfNoise(name, "the measurement noise", opts.MeasurementNoise, m, true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
x0, p0, err := kfInitialCondition(name, opts, d)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ft := kfTranspose(f, d, d)
|
||
ht := kfTranspose(h, m, d)
|
||
return kalmanRun(nMeas, m, d, meas, x0, p0, func(t int, zRow, x, p []float64) (kalmanStepOut, error) {
|
||
// Predict: x⁻ = F·x, P⁻ = F·P·Fᵀ + Q.
|
||
xp := kfMatVec(f, d, d, x)
|
||
pp := kfMatMul(kfMatMul(f, d, d, p, d), d, d, ft, d)
|
||
for i := range pp {
|
||
pp[i] += q[i]
|
||
}
|
||
predicted := kfMatVec(h, m, d, xp)
|
||
return kfCorrect(name, t+1, zRow, predicted, xp, pp, d, m, h, ht, r)
|
||
})
|
||
}
|
||
|
||
// ExtendedKalmanFilter runs the extended Kalman filter over the
|
||
// measurement stack z under the nonlinear model x_{t+1} = f(x_t) +
|
||
// w_t, z_t = h(x_t) + v_t: every step predicts by propagating the
|
||
// mean through f and the covariance through the linearisation F =
|
||
// ∂f/∂x at x̂_{t|t}, then corrects through the Jacobian H = ∂h/∂x at
|
||
// the predicted mean, with the same Joseph-form update and the same
|
||
// exact log-likelihood the linear filter carries. The Jacobians come
|
||
// from the options when supplied analytically and from central
|
||
// differences otherwise (see JacobianFunc).
|
||
//
|
||
// The extended filter is the linear one applied to local linear
|
||
// models: it inherits the Kalman recursions and with them the first
|
||
// order's blindness to the curvature of f and h, so a strongly bent
|
||
// observation map wants the unscented filter instead. The state
|
||
// dimension must be fixed by an initial state or covariance (see
|
||
// KalmanOptions); everything else follows the linear filter's
|
||
// contract, with a failing callback or Jacobian reported with the
|
||
// step it failed at.
|
||
func ExtendedKalmanFilter(z *core.Array, transition, observation StateFunc, opts KalmanOptions) (*KalmanResult, error) {
|
||
const name = "ExtendedKalmanFilter"
|
||
if transition == nil || observation == nil {
|
||
return nil, base.Errf("%s: the transition and observation maps are required", name)
|
||
}
|
||
nMeas, m, meas, err := kfMeasurements(name, z)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
d, x0, p0, q, r, err := kfNonlinearInputs(name, opts, m)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
fjac := opts.TransitionJacobian
|
||
if fjac == nil {
|
||
fjac = func(x *core.Array) (*core.Array, error) {
|
||
return core.Jacobian(transition, x, core.JacobianOptions{})
|
||
}
|
||
}
|
||
hjac := opts.ObservationJacobian
|
||
if hjac == nil {
|
||
hjac = func(x *core.Array) (*core.Array, error) {
|
||
return core.Jacobian(observation, x, core.JacobianOptions{})
|
||
}
|
||
}
|
||
return kalmanRun(nMeas, m, d, meas, x0, p0, func(t int, zRow, x, p []float64) (kalmanStepOut, error) {
|
||
xArr, err := core.FromFloats(x, d)
|
||
if err != nil {
|
||
return kalmanStepOut{}, err
|
||
}
|
||
fOut, err := transition(xArr)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: the transition: %w", name, t+1, err)
|
||
}
|
||
xp, err := kfVector(name, "the transition output", fOut, d)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err)
|
||
}
|
||
fJacArr, err := fjac(xArr)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: the transition Jacobian: %w", name, t+1, err)
|
||
}
|
||
fMat, err := kfMatrix(name, "the transition Jacobian", fJacArr, d, d)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err)
|
||
}
|
||
// Predict through the linearisation at the filtered mean.
|
||
pp := kfMatMul(kfMatMul(fMat, d, d, p, d), d, d, kfTranspose(fMat, d, d), d)
|
||
for i := range pp {
|
||
pp[i] += q[i]
|
||
}
|
||
// Correct through the linearisation at the predicted mean, but
|
||
// innovate against the true observation map.
|
||
xpArr, err := core.FromFloats(xp, d)
|
||
if err != nil {
|
||
return kalmanStepOut{}, err
|
||
}
|
||
hOut, err := observation(xpArr)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: the observation: %w", name, t+1, err)
|
||
}
|
||
predicted, err := kfVector(name, "the observation output", hOut, m)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err)
|
||
}
|
||
hJacArr, err := hjac(xpArr)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: the observation Jacobian: %w", name, t+1, err)
|
||
}
|
||
hMat, err := kfMatrix(name, "the observation Jacobian", hJacArr, m, d)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err)
|
||
}
|
||
return kfCorrect(name, t+1, zRow, predicted, xp, pp, d, m, hMat, kfTranspose(hMat, m, d), r)
|
||
})
|
||
}
|
||
|
||
// UnscentedKalmanFilter runs the unscented Kalman filter over the
|
||
// measurement stack z under the nonlinear model x_{t+1} = f(x_t) +
|
||
// w_t, z_t = h(x_t) + v_t: the state distribution N(x̂, P) is carried
|
||
// through f and h exactly to second order by the deterministic
|
||
// sigma-point set x̂ ± sqrt(d+λ)·L[:, i], L the Cholesky factor of P,
|
||
// whose weighted moments reconstruct the predicted mean and
|
||
// covariance. The update re-draws the set from the predicted
|
||
// distribution, so the state-observation cross-covariance carries the
|
||
// process noise the innovation covariance does. λ = α²(d+κ) − d, with
|
||
// d the state dimension the sigma-point set spans, comes from
|
||
// KalmanOptions' SigmaAlpha, SigmaBeta and SigmaKappa; the
|
||
// weights are the standard scaled set, with the covariance weight of
|
||
// the centre point carrying the 1 − α² + β prior correction.
|
||
//
|
||
// The correction carries no Joseph form: the gain comes from the
|
||
// explicit cross-covariance of state and observation rather than an
|
||
// observation matrix, so the covariance leaves the update as
|
||
// P⁻ − K·S·Kᵀ mirrored into its symmetric average, and positive
|
||
// definiteness is enforced by the next predict's Cholesky
|
||
// factorisation, which names the step when it fails. The log-likelihood
|
||
// accumulates exactly as in the linear filter. On a linear model the
|
||
// sigma transforms are exact and the filter degenerates to the
|
||
// Kalman answer to rounding; the state dimension must be fixed by an
|
||
// initial state or covariance (see KalmanOptions).
|
||
func UnscentedKalmanFilter(z *core.Array, transition, observation StateFunc, opts KalmanOptions) (*KalmanResult, error) {
|
||
const name = "UnscentedKalmanFilter"
|
||
if transition == nil || observation == nil {
|
||
return nil, base.Errf("%s: the transition and observation maps are required", name)
|
||
}
|
||
nMeas, m, meas, err := kfMeasurements(name, z)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
d, x0, p0, q, r, err := kfNonlinearInputs(name, opts, m)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
alpha := opts.SigmaAlpha
|
||
if alpha == 0 {
|
||
alpha = 0.001
|
||
}
|
||
beta := opts.SigmaBeta
|
||
if beta == 0 {
|
||
beta = 2
|
||
}
|
||
kappa := opts.SigmaKappa
|
||
if math.IsNaN(alpha) || alpha < 0 {
|
||
return nil, base.Errf("%s: the sigma alpha must be unset (the default 0.001) or positive, got %g",
|
||
name, opts.SigmaAlpha)
|
||
}
|
||
if math.IsNaN(beta) || beta < 0 {
|
||
return nil, base.Errf("%s: the sigma beta must be unset (the default 2) or positive, got %g",
|
||
name, opts.SigmaBeta)
|
||
}
|
||
if math.IsNaN(kappa) {
|
||
return nil, base.Errf("%s: the sigma kappa must not be NaN, got %g", name, kappa)
|
||
}
|
||
scale := alpha * alpha * (float64(d) + kappa)
|
||
if scale <= 0 {
|
||
return nil, base.Errf("%s: the sigma spread vanishes: alpha %g and kappa %g leave no positive scale for %d states",
|
||
name, alpha, kappa, d)
|
||
}
|
||
points := 2*d + 1
|
||
lambda := scale - float64(d)
|
||
wm := make([]float64, points)
|
||
wc := make([]float64, points)
|
||
wm[0] = lambda / scale
|
||
wc[0] = wm[0] + 1 - alpha*alpha + beta
|
||
for i := 1; i < points; i++ {
|
||
wm[i] = 1 / (2 * scale)
|
||
wc[i] = wm[i]
|
||
}
|
||
spread := math.Sqrt(scale)
|
||
sig := make([]float64, points*d)
|
||
prop := make([]float64, points*d)
|
||
obs := make([]float64, points*m)
|
||
return kalmanRun(nMeas, m, d, meas, x0, p0, func(t int, zRow, x, p []float64) (kalmanStepOut, error) {
|
||
// The sigma set: the mean plus each Cholesky column of the
|
||
// covariance, pushed out by sqrt(d+λ) both ways.
|
||
l, err := kfCholesky("the state covariance", p, d)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err)
|
||
}
|
||
copy(sig[:d], x)
|
||
for i := range d {
|
||
plus := sig[(1+2*i)*d : (2+2*i)*d]
|
||
minus := sig[(2+2*i)*d : (3+2*i)*d]
|
||
copy(plus, x)
|
||
copy(minus, x)
|
||
for j := range d {
|
||
v := spread * l[j*d+i]
|
||
plus[j] += v
|
||
minus[j] -= v
|
||
}
|
||
}
|
||
// Predict: every sigma through f, then the weighted moments.
|
||
for s := range points {
|
||
sa, err := core.FromFloats(sig[s*d:(s+1)*d], d)
|
||
if err != nil {
|
||
return kalmanStepOut{}, err
|
||
}
|
||
fa, err := transition(sa)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: the transition: %w", name, t+1, err)
|
||
}
|
||
fv, err := kfVector(name, "the transition output", fa, d)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err)
|
||
}
|
||
copy(prop[s*d:(s+1)*d], fv)
|
||
}
|
||
xp := make([]float64, d)
|
||
for s := range points {
|
||
for j := range d {
|
||
xp[j] += wm[s] * prop[s*d+j]
|
||
}
|
||
}
|
||
pp := make([]float64, d*d)
|
||
for s := range points {
|
||
w := wc[s]
|
||
prows := prop[s*d : (s+1)*d]
|
||
for a := range d {
|
||
da := prows[a] - xp[a]
|
||
for b := range d {
|
||
pp[a*d+b] += w * da * (prows[b] - xp[b])
|
||
}
|
||
}
|
||
}
|
||
for i := range pp {
|
||
pp[i] += q[i]
|
||
}
|
||
kfSymmetrise(pp, d)
|
||
// The update re-draws the sigma set from the predicted
|
||
// distribution N(x⁻, P⁻), the process noise included: the
|
||
// state-observation cross-covariance must carry the same
|
||
// spread the innovation covariance does, or the gain loses the
|
||
// Q·Hᵀ term and a linear model stops degenerating to the
|
||
// Kalman answer.
|
||
lp2, err := kfCholesky("the predicted state covariance", pp, d)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err)
|
||
}
|
||
copy(sig[:d], xp)
|
||
for i := range d {
|
||
plus := sig[(1+2*i)*d : (2+2*i)*d]
|
||
minus := sig[(2+2*i)*d : (3+2*i)*d]
|
||
copy(plus, xp)
|
||
copy(minus, xp)
|
||
for j := range d {
|
||
v := spread * lp2[j*d+i]
|
||
plus[j] += v
|
||
minus[j] -= v
|
||
}
|
||
}
|
||
// Observation: the predicted sigmas through h, the weighted
|
||
// moments, and the state-observation cross-covariance.
|
||
for s := range points {
|
||
pa, err := core.FromFloats(sig[s*d:(s+1)*d], d)
|
||
if err != nil {
|
||
return kalmanStepOut{}, err
|
||
}
|
||
ha, err := observation(pa)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: the observation: %w", name, t+1, err)
|
||
}
|
||
hv, err := kfVector(name, "the observation output", ha, m)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err)
|
||
}
|
||
copy(obs[s*m:(s+1)*m], hv)
|
||
}
|
||
zp := make([]float64, m)
|
||
for s := range points {
|
||
for j := range m {
|
||
zp[j] += wm[s] * obs[s*m+j]
|
||
}
|
||
}
|
||
s := make([]float64, m*m)
|
||
for k := range points {
|
||
w := wc[k]
|
||
orow := obs[k*m : (k+1)*m]
|
||
for a := range m {
|
||
da := orow[a] - zp[a]
|
||
for b := range m {
|
||
s[a*m+b] += w * da * (orow[b] - zp[b])
|
||
}
|
||
}
|
||
}
|
||
for i := range s {
|
||
s[i] += r[i]
|
||
}
|
||
kfSymmetrise(s, m)
|
||
cross := make([]float64, d*m)
|
||
for k := range points {
|
||
w := wc[k]
|
||
prow := sig[k*d : (k+1)*d]
|
||
orow := obs[k*m : (k+1)*m]
|
||
for a := range d {
|
||
da := prow[a] - xp[a]
|
||
for b := range m {
|
||
cross[a*m+b] += w * da * (orow[b] - zp[b])
|
||
}
|
||
}
|
||
}
|
||
ls, err := kfCholesky("the innovation covariance", s, m)
|
||
if err != nil {
|
||
return kalmanStepOut{}, base.Errf("%s: at step %d: %w", name, t+1, err)
|
||
}
|
||
innovation := make([]float64, m)
|
||
for i := range m {
|
||
innovation[i] = zRow[i] - zp[i]
|
||
}
|
||
solved := kfCholSolve(ls, m, innovation)
|
||
quad := 0.0
|
||
for i := range m {
|
||
quad += innovation[i] * solved[i]
|
||
}
|
||
// K = P_xz·S⁻¹ through the transposed solve: S·X = P_xzᵀ
|
||
// gives X = S⁻¹·P_xzᵀ and K = Xᵀ.
|
||
gain := kfTranspose(kfCholSolveMatrix(ls, m, kfTranspose(cross, d, m), d), m, d)
|
||
mean := make([]float64, d)
|
||
copy(mean, xp)
|
||
for i := range d {
|
||
total := 0.0
|
||
grow := gain[i*m : (i+1)*m]
|
||
for j, y := range innovation {
|
||
total += grow[j] * y
|
||
}
|
||
mean[i] += total
|
||
}
|
||
kskt := kfMatMul(kfMatMul(gain, d, m, s, m), d, m, kfTranspose(gain, d, m), d)
|
||
covariance := make([]float64, d*d)
|
||
for i := range covariance {
|
||
covariance[i] = pp[i] - kskt[i]
|
||
}
|
||
kfSymmetrise(covariance, d)
|
||
logLik := -0.5 * (float64(m)*math.Log(2*math.Pi) + kfLogDet(ls, m) + quad)
|
||
return kalmanStepOut{mean: mean, covariance: covariance, innovation: innovation,
|
||
innovationCov: s, logLikelihood: logLik}, nil
|
||
})
|
||
}
|