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
|
|||
|
|
})
|
|||
|
|
}
|