Files
tensor/signal/kalman.go
T

942 lines
32 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"
"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
})
}