Skip to content

Commit 5f360a9

Browse files
committed
work in progress
1 parent e4de566 commit 5f360a9

6 files changed

Lines changed: 106 additions & 128 deletions

File tree

docs/source/kalman_smooth.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ kalman_smooth
88
.. autofunction:: pynumdiff.kalman_smooth.constant_velocity
99
.. autofunction:: pynumdiff.kalman_smooth.constant_acceleration
1010
.. autofunction:: pynumdiff.kalman_smooth.constant_jerk
11-
.. autofunction:: pynumdiff.kalman_smooth.known_dynamics
11+
.. autofunction:: pynumdiff.kalman_smooth.kalman_filter
12+
.. autofunction:: pynumdiff.kalman_smooth.rts_smooth

pynumdiff/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
from .smooth_finite_difference import meandiff, mediandiff, gaussiandiff, friedrichsdiff, butterdiff
77
from .polynomial_fit import splinediff, polydiff, savgoldiff
88
from .total_variation_regularization import tvrdiff, velocity, acceleration, jerk, iterative_velocity, smooth_acceleration, jerk_sliding
9-
from .kalman_smooth import rtsdiff, constant_velocity, constant_acceleration, constant_jerk, known_dynamics
9+
from .kalman_smooth import kalman_filter, rts_smooth, rtsdiff, constant_velocity, constant_acceleration, constant_jerk
1010
from .linear_model import spectraldiff, lineardiff, rbfdiff
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""This module implements Kalman filters
22
"""
3-
from ._kalman_smooth import rtsdiff, constant_velocity, constant_acceleration, constant_jerk, known_dynamics
3+
from ._kalman_smooth import kalman_filter, rts_smooth, rtsdiff, constant_velocity, constant_acceleration, constant_jerk
Lines changed: 100 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,106 @@
11
import numpy as np
22
from warnings import warn
3+
from scipy.linalg import expm
34

4-
####################
5-
# Helper functions #
6-
####################
7-
def _kalman_forward_filter(xhat0, P0, y, A, C, Q, R, u=None, B=None):
8-
"""Run the forward pass of a Kalman filter
5+
6+
def kalman_filter(y, _t, xhat0, P0, A, Q, C, R, B=None, u=None, save_P=True):
7+
"""Run the forward pass of a Kalman filter, with regular or irregular step size.
8+
9+
:param np.array y: series of measurements, stacked along axis 0.
10+
:param float or array[float] _t: This function supports variable step size. This parameter is either the constant
11+
dt if given as a single float, or data locations if given as an array of same length as :code:`x`.
912
:param np.array xhat0: initial estimate of state (often 0) one step before the first measurement
1013
:param np.array P0: initial guess of state covariance (often identity matrix) before the first measurement
11-
:param np.array y: series of measurements, stacked along axis 0. In this case just the raw noisy data (fully observable)
12-
:param np.array A: discrete-time state transition matrix
14+
:param np.array A: state transition matrix, in discrete time if constant dt, in continuous time if variable dt
15+
:param np.array Q: noise covariance matrix, in discrete time if constant dt, in continuous time if variable dt
1316
:param np.array C: measurement matrix
14-
:param np.array Q: discrete-time process noise covariance
1517
:param np.array R: measurement noise covariance
18+
:param np.array B: optional control matrix, in discrete time if constant dt, in continuous time if variable dt
1619
:param np.array u: optional control input
17-
:param np.array B: optional control matrix
20+
:param bool save_P: whether to save history of error covariance and a priori state estimates, used with rts
21+
smoothing but nonstandard to compute for ordinary filtering
22+
1823
:return: tuple[np.array, np.array, np.array, np.array] of\n
1924
- **xhat_pre** -- a priori estimates of xhat, with axis=0 the batch dimension, so xhat[n] gets the nth step
2025
- **xhat_post** -- a posteriori estimates of xhat
2126
- **P_pre** -- a priori estimates of P
2227
- **P_post** -- a posteriori estimates of P
28+
if :code:`save_P` is :code:`True` else only **xhat_post** to save memory
2329
"""
24-
if B is None: B = np.zeros((A.shape[0], 1))
25-
if u is None: u = np.zeros(B.shape[1])
30+
# _pre variables are a priori predictions based on only past information
31+
# _post variables are a posteriori combinations of all information available at the current step
32+
xhat_post = []
33+
if save_P: xhat_pre = []; P_pre = []; P_post = []
2634
xhat = xhat0
2735
P = P0
2836

29-
# _pre variables are a priori predictions based on only past information
30-
# _post variables are a posteriori combinations of all information available at the current step
31-
xhat_pre = []; xhat_post = []; P_pre = []; P_post = []
32-
37+
equispaced = isinstance(_t, (float, int)) # I'd rather not call isinstance in the loop
38+
control = isinstance(B, np.ndarray) and isinstance(B, np.ndarray) # whether there is a control input
3339
for n in range(y.shape[0]):
34-
xhat_ = A @ xhat + B @ u # ending underscores denote an a priori prediction
35-
P_ = A @ P @ A.T + Q
36-
xhat_pre.append(xhat_) # the [n]th index holds _{n|n-1} info
37-
P_pre.append(P_)
38-
39-
xhat = xhat_.copy() # copies very important here. See #122
40+
if not equispaced:
41+
M = expm(np.block([[A, Q],[numpy.zeros(A.shape), -A.T]]) * (_t[n] - _t[n-1])) # form discrete-time matrices TODO doesn't work at n=0
42+
An = M[:order+1,:order+1] # upper left block
43+
Qn = M[:order+1,order+1:] @ An.T # upper right block
44+
if control:
45+
M = expm(np.block([[A, B], [np.zeros((A.shape[0], 2*A.shape[1]))]]))
46+
Bn = M[:order+1,order+1:] # upper right block
47+
else: # matrices should already be discrete time
48+
An, Qn, Bn = A, Q, B
49+
50+
xhat_ = An @ xhat + Bn @ u if control else An @ xhat # ending underscores denote an a priori prediction
51+
P_ = An @ P @ An.T + Qn # the dense matrix multiplies here are the most expensive step
52+
xhat = xhat_.copy() # copies, lest modifications to these variables change the a priori estimates. See #122
4053
P = P_.copy()
4154
if not np.isnan(y[n]): # handle missing data
4255
K = P_ @ C.T @ np.linalg.inv(C @ P_ @ C.T + R)
4356
xhat += K @ (y[n] - C @ xhat_)
4457
P -= K @ C @ P_
45-
xhat_post.append(xhat) # the [n]th index holds _{n|n} info
46-
P_post.append(P)
47-
48-
return np.stack(xhat_pre, axis=0), np.stack(xhat_post, axis=0), np.stack(P_pre, axis=0), np.stack(P_post, axis=0)
49-
50-
51-
def _kalman_backward_smooth(A, xhat_pre, xhat_post, P_pre, P_post):
52-
"""Do the additional Rauch-Tung-Striebel smoothing step
53-
:param A: discrete-time state transition matrix
54-
:param xhat_pre: a priori estimates of xhat
55-
:param xhat_post: a posteriori estimates of xhat
56-
:param P_pre: a priori estimates of P
57-
:param P_post: a posteriori estimates of P
58+
# the [n]th index of pre variables holds _{n|n-1} info; the [n]th index of post variables holds _{n|n} info
59+
xhat_post.append(xhat)
60+
if save_P: xhat_pre.append(xhat_); P_pre.append(P_); P_post.append(P)
61+
62+
xhat_post = np.stack(xhat_post, axis=0)
63+
return xhat_post if not save_P else (np.stack(xhat_pre, axis=0), xhat_post,
64+
np.stack(P_pre, axis=0), np.stack(P_post, axis=0))
65+
66+
67+
def rts_smooth(_t, A, xhat_pre, xhat_post, P_pre, P_post, compute_P_smooth=False):
68+
"""Perform Rauch-Tung-Striebel smoothing, using information from forward Kalman filtering.
69+
70+
:param float or array[float] _t: This function supports variable step size. This parameter is either the constant
71+
dt if given as a single float, or data locations if given as an array of same length as :code:`x`.
72+
:param np.array A: state transition matrix, in discrete time if constant dt, in continuous time if variable dt
73+
:param list[np.array] xhat_pre: a priori estimates of xhat from a kalman_filter forward pass
74+
:param list[np.array] xhat_post: a posteriori estimates of xhat from a kalman_filter forward pass
75+
:param list[np.array] P_pre: a priori estimates of P from a kalman_filter forward pass
76+
:param list[np.array] P_post: a posteriori estimates of P from a kalman_filter forward pass
77+
:param bool compute_P_smooth: it's extra work if you don't need it
78+
5879
:return: tuple[np.array, np.array] of\n
5980
- **xhat_smooth** -- RTS smoothed xhat
6081
- **P_smooth** -- RTS smoothed P
82+
if :code:`compute_P_smooth` is :code:`True` else only **xhat_smooth**
6183
"""
62-
xhat_smooth = [xhat_post[-1]]
63-
P_smooth = [P_post[-1]]
84+
xhat_smooth = np.empty(xhat_post.shape); xhat_smooth[-1] = xhat_post[-1] # preallocate arrays
85+
if compute_P_smooth: P_smooth = np.empty(P_post.shape); P_smooth[-1] = P_post[-1]
86+
87+
equispaced = isinstance(_t, (int, float)) # I'd rather not call isinstance in a loop when it's avoidable
6488
for n in range(xhat_pre.shape[0]-2, -1, -1):
65-
C_RTS = P_post[n] @ A.T @ np.linalg.inv(P_pre[n+1]) # the [n+1]th index holds _{n+1|n} info
66-
xhat_smooth.append(xhat_post[n] + C_RTS @ (xhat_smooth[-1] - xhat_pre[n+1])) # The original authors use C, not to
67-
P_smooth.append(P_post[n] + C_RTS @ (P_smooth[-1] - P_pre[n+1]) @ C_RTS.T) # be confused with the measurement matrix
89+
An = A if equispaced else expm(A * (_t[n+1] - _t[n])) # state transition from n to n+1, per the paper
90+
C_RTS = P_post[n] @ An.T @ np.linalg.inv(P_pre[n+1]) # the [n+1]th index holds _{n+1|n} info
91+
xhat_smooth[n] = xhat_post[n] + C_RTS @ (xhat_smooth[n+1] - xhat_pre[n+1]) # The original authors use C, not to be confused
92+
if compute_P_smooth: P_smooth[n] = P_post[n] + C_RTS @ (P_smooth[n+1] - P_pre[n+1]) @ C_RTS.T # with the measurement matrix
6893

69-
return np.stack(xhat_smooth[::-1], axis=0), np.stack(P_smooth[::-1], axis=0) # reverse lists
94+
return xhat_smooth if not compute_P_smooth else (xhat_smooth, P_smooth)
7095

7196

72-
def _RTS_smooth(xhat0, P0, y, A, C, Q, R, u=None, B=None):
73-
"""forward-backward Kalman/Rauch-Tung-Striebel smoother. For params see the helper functions.
74-
"""
75-
xhat_pre, xhat_post, P_pre, P_post = _kalman_forward_filter(xhat0, P0, y, A, C, Q, R) # noisy x are the "y" in Kalman-land
76-
xhat_smooth, _ = _kalman_backward_smooth(A, xhat_pre, xhat_post, P_pre, P_post) # not doing anything with P_smooth
77-
return xhat_smooth
78-
79-
80-
#########################################
81-
# Constant 1st, 2nd, and 3rd derivative #
82-
#########################################
83-
def rtsdiff(x, dt, order, qr_ratio, forwardbackward):
97+
def rtsdiff(x, _t, order, qr_ratio, forwardbackward):
8498
"""Perform Rauch-Tung-Striebel smoothing with a naive constant derivative model. Other constant derivative
8599
methods in this module call this function.
86100
87101
:param np.array[float] x: data series to differentiate
88-
:param float dt: step size
102+
:param float or array[float] _t: This function supports variable step size. This parameter is either the constant
103+
dt if given as a single float, or data locations if given as an array of same length as :code:`x`.
89104
:param int order: which derivative to stabilize in the constant-derivative model
90105
:param qr_ratio: the process noise level of the divided by the measurement noise level, because,
91106
per `our analysis <https://github.com/florisvb/PyNumDiff/issues/139>`_, the mean result is
@@ -97,49 +112,49 @@ def rtsdiff(x, dt, order, qr_ratio, forwardbackward):
97112
- **x_hat** -- estimated (smoothed) x
98113
- **dxdt_hat** -- estimated derivative of x
99114
"""
115+
if isinstance(_t, (np.ndarray, list)) and len(x) != len(_t):
116+
raise ValueError("If `_t` is given as array-like, must have same length as `x`.")
117+
100118
q = 10**int(np.log10(qr_ratio)/2) # even-ish split of the powers across 0
101119
r = q/qr_ratio
102120

103-
if order == 1:
104-
A = np.array([[1, dt], [0, 1]]) # states are x, x'. This basically does an Euler update.
105-
C = np.array([[1, 0]]) # we measure only y = noisy x
106-
R = np.array([[r]])
107-
Q = np.array([[1e-16, 0], [0, q]]) # uncertainty is around the velocity
108-
P0 = np.array(100*np.eye(2)) # See #110 for why this choice of P0
109-
elif order == 2:
110-
A = np.array([[1, dt, (dt**2)/2], # states are x, x', x"
111-
[0, 1, dt],
112-
[0, 0, 1]])
113-
C = np.array([[1, 0, 0]]) # we measure only y = noisy x
114-
R = np.array([[r]])
115-
Q = np.array([[1e-16, 0, 0],
116-
[0, 1e-16, 0],
117-
[0, 0, q]]) # uncertainty is around the acceleration
118-
P0 = np.array(100*np.eye(3)) # See #110 for why this choice of P0
119-
elif order == 3:
120-
A = np.array([[1, dt, (dt**2)/2, (dt**3)/6], # states are x, x', x", x'"
121-
[0, 1, dt, (dt**2)/2],
122-
[0, 0, 1, dt],
123-
[0, 0, 0, 1]])
124-
C = np.array([[1, 0, 0, 0]]) # we measure only y = noisy x
125-
R = np.array([[r]])
126-
Q = np.array([[1e-16, 0, 0, 0],
127-
[0, 1e-16, 0, 0],
128-
[0, 0, 1e-16, 0],
129-
[0, 0, 0, q]]) # uncertainty is around the jerk
130-
P0 = np.array(100*np.eye(4)) # See #110 for why this choice of P0
131-
121+
A = np.diag(np.ones(order), 1) # continuous-time A just has 1s on the first diagonal (where 0th is main diagonal)
122+
Q = np.zeros(A.shape); Q[-1,-1] = q # continuous-time uncertainty around the last derivative
123+
C = np.zeros((1, order+1)); C[0,0] = 1 # we measure only y = noisy x
124+
R = np.array([[r]]) # 1 observed state, so this is 1x1
125+
P0 = 100*np.eye(order+1) # See #110 for why this choice of P0
132126
xhat0 = np.zeros(A.shape[0]); xhat0[0] = x[0] # The first estimate is the first seen state. See #110
133-
xhat_smooth = _RTS_smooth(xhat0, P0, x, A, C, Q, R) # noisy x are the "y" in Kalman-land
127+
128+
if isinstance(_t, (float, int)):
129+
M = expm(np.block([[A, Q],[numpy.zeros(A.shape), -A.T]]) * _t) # form discrete-time versions
130+
A = M[:order+1,:order+1]
131+
Q = M[:order+1,order+1:] @ A.T
132+
133+
xhat_pre, xhat_post, P_pre, P_post = kalman_filter(x, _t, xhat0, P0, A, Q, C, R) # noisy x are the "y" in Kalman-land
134+
xhat_smooth = rts_smooth(_t, A, xhat_pre, xhat_post, P_pre, P_post) # not doing anything with P_smooth
134135
x_hat_forward = xhat_smooth[:, 0] # first dimension is time, so slice first element at all times
135136
dxdt_hat_forward = xhat_smooth[:, 1]
136137

137138
if not forwardbackward: # bounce out here if not doing the same in reverse and then combining
138139
return x_hat_forward, dxdt_hat_forward
139140

141+
# for backward case, if discrete time invert the matrix, and for continuous time reverse _t, because then dts will go negative
140142
xhat0[0] = x[-1] # starting from the other end of the signal
141-
Ainv = np.linalg.inv(A) # dynamics are inverted
142-
xhat_smooth = _RTS_smooth(xhat0, P0, x[::-1], Ainv, C, Q, R) # noisy x are the "y" in Kalman-land
143+
144+
### TODO fix between here and next hashes
145+
if isinstance(_t, (float, int)):
146+
M = expm(np.block([[A, Q],[numpy.zeros(A.shape), -A.T]]) * -_t) # form discrete-time versions
147+
A = M[:order+1,:order+1] # inverse of A
148+
Q = M[:order+1,order+1:] @ A.T #
149+
150+
Ainv = np.linalg.inv(A_c) # dynamics are inverted, same as expm() with negative dt
151+
_t_rev = _t[::-1] _t
152+
153+
xhat_pre, xhat_post, P_pre, P_post = _kalman_forward_filter(x[::-1], _t[::-1], xhat0, P0, A, Q, C, R) if \
154+
isinstance(_t, (np.ndarray, list)) else _kalman_forward_filter(x[::-1], _t, xhat0, P0, np.linalg.inv(A), Q, C, R)
155+
xhat_smooth = _kalman_backward_smooth(_t_rev, A_c, xhat_pre, xhat_post, P_pre, P_post)
156+
###
157+
143158
x_hat_backward = xhat_smooth[:, 0][::-1] # the result is backwards still, so reverse it
144159
dxdt_hat_backward = xhat_smooth[:, 1][::-1]
145160

@@ -235,41 +250,3 @@ def constant_jerk(x, dt, params=None, options=None, r=None, q=None, forwardbackw
235250
raise ValueError("`q` and `r` must be given.")
236251

237252
return rtsdiff(x, dt, 3, q/r, forwardbackward)
238-
239-
240-
def known_dynamics(x, params, u=None, options=None, xhat0=None, P0=None, A=None,
241-
B=0, C=None, Q=None, R=None, smooth=True):
242-
"""Run a forward RTS Kalman smoother given known dynamics.
243-
244-
:param np.array[float] x: data series of noisy measurements
245-
:param list params: (**deprecated**, prefer :code:`xhat0`, :code:`P0`, :code:`A`,
246-
:code:`B`, :code:`C`, :code:`R`, and :code:`Q`), a list in the order here (note flip of Q and R)
247-
:param np.array[float] u: series of control inputs
248-
:param options: (**deprecated**, prefer :code:`smooth`)
249-
a dictionary consisting of {'smooth': (bool)}
250-
:param np.array xhat0: inital condition, length N = number of states
251-
:param np.array P0: initial covariance matrix of NxN
252-
:param np.array A: dynamics matrix, NxN
253-
:param np.array B: control input matrix, NxM, M = number of measurements
254-
:param np.array C: measurement dynamics, MxN
255-
:param np.array Q: covariance matrix for the model, NxN
256-
:param np.array R: covariance matrix for the measurements, MxM
257-
:param bool smooth: whether to run the RTS smoother step
258-
259-
:return: np.array **x_hat** -- estimated (smoothed) x
260-
""" # Why not also returning derivative here?
261-
if params != None:
262-
warn("`params` and `options` parameters will be removed in a future version. Use `xhat0`, " +
263-
"`P0`, `A`, `B`, `C`, `Q`, `R`, and `smooth` instead.", DeprecationWarning)
264-
xhat0, P0, A, B, C, R, Q = params
265-
if options != None:
266-
if 'smooth' in options: smooth = options['smooth']
267-
elif None in [xhat0, P0, A, C, R, Q]:
268-
raise ValueError("`xhat0`, `P0`, `A`, `C`, `Q`, and `R` must be given.")
269-
270-
xhat_pre, xhat_post, P_pre, P_post = _kalman_forward_filter(xhat0, P0, x, A, C, Q, R, u, B) # noisy x are the "y" in Kalman-land
271-
if not smooth:
272-
return xhat_post
273-
274-
xhat_smooth, _ = _kalman_backward_smooth(A, xhat_pre, xhat_post, P_pre, P_post)
275-
return xhat_smooth # We're not calculating a derivative here. Why not?

pynumdiff/linear_model/_linear_model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ def rbfdiff(x, _t, sigma=1, lmbd=0.01):
238238
239239
:param np.array[float] x: data to differentiate
240240
:param float or array[float] _t: This function supports variable step size. This parameter is either the constant
241-
step size if given as a single float, or data locations if given as an array of same length as :code:`x`.
241+
dt if given as a single float, or data locations if given as an array of same length as :code:`x`.
242242
:param float sigma: controls width of radial basis function
243243
:param float lmbd: controls strength of bias toward data
244244

pynumdiff/polynomial_fit/_polynomial_fit.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def splinediff(x, _t, params=None, options={}, degree=3, s=None, num_iterations=
1111
1212
:param np.array[float] x: data to differentiate
1313
:param float or array[float] _t: This function supports variable step size. This parameter is either the constant
14-
step size if given as a single float, or data locations if given as an array of same length as :code:`x`.
14+
dt if given as a single float, or data locations if given as an array of same length as :code:`x`.
1515
:param list params: (**deprecated**, prefer :code:`degree`, :code:`cutoff_freq`, and :code:`num_iterations`)
1616
:param dict options: (**deprecated**, prefer :code:`num_iterations`) an empty dictionary or {'iterate': (bool)}
1717
:param int degree: polynomial degree of the spline. A kth degree spline can be differentiated k times.

0 commit comments

Comments
 (0)