Skip to content

Commit 58f4918

Browse files
authored
Merge pull request #189 from mariaprot/rtsdiff
This continues us toward resolving #76
2 parents fafb87c + 2d66a6e commit 58f4918

1 file changed

Lines changed: 76 additions & 79 deletions

File tree

pynumdiff/kalman_smooth.py

Lines changed: 76 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,19 @@
88
from pynumdiff.utils.utility import huber_const
99

1010

11-
def kalman_filter(y, dt_or_t, xhat0, P0, A, Q, C, R, B=None, u=None, save_P=True):
12-
"""Run the forward pass of a Kalman filter, with regular or irregular step size.
11+
def kalman_filter(y, xhat0, P0, A, Q, C, R, B=None, u=None, save_P=True):
12+
"""Run the forward pass of a Kalman filter. Expects discrete-time matrices; use :func:`scipy.linalg.expm`
13+
in the caller to convert from continuous time if needed.
1314
1415
:param np.array y: series of measurements, stacked in the direction of axis 0.
15-
:param float or array[float] dt_or_t: This function supports variable step size. This parameter is either the constant
16-
step size if given as a single float, or sample locations if given as an array of same length as :code:`x`.
1716
:param np.array xhat0: a priori guess of initial state at the time of the first measurement
1817
:param np.array P0: a priori guess of state covariance at the time of first measurement (often identity matrix)
19-
:param np.array A: state transition matrix, in discrete time if constant dt, in continuous time if variable dt
20-
:param np.array Q: noise covariance matrix, in discrete time if constant dt, in continuous time if variable dt
18+
:param np.array A: discrete-time state transition matrix. If 2D (:math:`m \\times m`), the same matrix is used for all steps;
19+
if 3D (:math:`N-1 \\times m \\times m`), :code:`A[n-1]` is used for the transition from step :math:`n-1` to :math:`n`.
20+
:param np.array Q: discrete-time process noise covariance. Same 2D or 3D shape convention as :code:`A`.
2121
:param np.array C: measurement matrix
2222
:param np.array R: measurement noise covariance
23-
:param np.array B: optional control matrix, in discrete time if constant dt, in continuous time if variable dt
23+
:param np.array B: optional discrete-time control matrix, optionally stacked like :code:`A`.
2424
:param np.array u: optional control inputs, stacked in the direction of axis 0
2525
:param bool save_P: whether to save history of error covariance and a priori state estimates, used with rts
2626
smoothing but nonstandard to compute for ordinary filtering
@@ -31,37 +31,26 @@ def kalman_filter(y, dt_or_t, xhat0, P0, A, Q, C, R, B=None, u=None, save_P=True
3131
- **P_post** (np.array) -- a posteriori estimates of P
3232
if :code:`save_P` is :code:`True` else only **xhat_post** to save memory
3333
"""
34+
if A.ndim != Q.ndim: raise ValueError("number of dimensions of A and Q must agree, either single matrix or stacked")
35+
3436
N = y.shape[0]
3537
m = xhat0.shape[0] # dimension of the state
3638
xhat_post = np.empty((N,m))
3739
if save_P:
3840
xhat_pre = np.empty((N,m)) # _pre = a priori predictions based on only past information
3941
P_pre = np.empty((N,m,m)) # _post = a posteriori combinations of all information available at a step
4042
P_post = np.empty((N,m,m))
41-
# determine some things ahead of the loop
42-
equispaced = np.isscalar(dt_or_t)
43+
4344
control = isinstance(B, np.ndarray) and isinstance(u, np.ndarray) # whether there is a control input
44-
if equispaced:
45-
An, Qn, Bn = A, Q, B # in this case only need to assign once
46-
else:
47-
M = np.block([[A, Q],[np.zeros(A.shape), -A.T]]) # If variable dt, we'll exponentiate this a bunch
48-
if control: Mc = np.block([[A, B],[np.zeros((A.shape[0], 2*A.shape[1]))]])
45+
if A.ndim == 2: An, Qn, Bn = A, Q, B # single matrices, assign once outside the loop
4946

5047
for n in range(N):
5148
if n == 0: # first iteration is a special case, involving less work
5249
xhat_ = xhat0
5350
P_ = P0
5451
else:
55-
if not equispaced:
56-
dt = dt_or_t[n] - dt_or_t[n-1] # vector of t in this case
57-
eM = expm(M * dt) # form discrete-time matrices
58-
An = eM[:m,:m] # upper left block
59-
Qn = eM[:m,m:] @ An.T # upper right block
60-
if dt < 0: Qn = np.abs(Qn) # eigenvalues go negative if reverse time, but noise shouldn't shrink
61-
if control:
62-
eM = expm(Mc * dt)
63-
Bn = eM[:m,m:] # upper right block
64-
xhat_ = An @ xhat + Bn @ u[n] if control else An @ xhat # ending underscores denote an a priori prediction
52+
if A.ndim == 3: An, Qn = A[n-1], Q[n-1]; Bn = B[n-1] if control else B # use the matrices corresponding to this step
53+
xhat_ = An @ xhat + Bn @ u[n] if control else An @ xhat # ending underscores denote a priori predictions
6554
P_ = An @ P @ An.T + Qn # the dense matrix multiplies here are the most expensive step
6655

6756
xhat = xhat_.copy() # copies, lest modifications to these variables change the a priori estimates. See #122
@@ -77,12 +66,11 @@ def kalman_filter(y, dt_or_t, xhat0, P0, A, Q, C, R, B=None, u=None, save_P=True
7766
return xhat_post if not save_P else (xhat_pre, xhat_post, P_pre, P_post)
7867

7968

80-
def rts_smooth(dt_or_t, A, xhat_pre, xhat_post, P_pre, P_post, compute_P_smooth=True):
69+
def rts_smooth(A, xhat_pre, xhat_post, P_pre, P_post, compute_P_smooth=True):
8170
"""Perform Rauch-Tung-Striebel smoothing, using information from forward Kalman filtering.
8271
83-
:param float or array[float] dt_or_t: This function supports variable step size. This parameter is either the constant
84-
step size if given as a single float, or sample locations if given as an array of same length as the state histories.
85-
:param np.array A: state transition matrix, in discrete time if constant dt, in continuous time if variable dt
72+
:param np.array A: discrete-time state transition matrix. If 2D (:math:`m \\times m`), the same matrix is used for all steps;
73+
if 3D (:math:`N-1 \\times m \\times m`), :code:`A[n-1]` is used for the transition from step :math:`n-1` to :math:`n`.
8674
:param np.array xhat_pre: a priori estimates of xhat from a kalman_filter forward pass
8775
:param np.array xhat_post: a posteriori estimates of xhat from a kalman_filter forward pass
8876
:param np.array P_pre: a priori estimates of P from a kalman_filter forward pass
@@ -96,77 +84,84 @@ def rts_smooth(dt_or_t, A, xhat_pre, xhat_post, P_pre, P_post, compute_P_smooth=
9684
xhat_smooth = np.empty(xhat_post.shape); xhat_smooth[-1] = xhat_post[-1] # preallocate arrays
9785
if compute_P_smooth: P_smooth = np.empty(P_post.shape); P_smooth[-1] = P_post[-1]
9886

99-
equispaced = np.isscalar(dt_or_t) # to avoid calling this in the loop
100-
if equispaced: An = A # in this case only assign once, outside the loop
87+
if A.ndim == 2: An = A # single matrix, assign once outside the loop
10188
for n in range(xhat_pre.shape[0]-2, -1, -1):
102-
if not equispaced: An = expm(A * (dt_or_t[n+1] - dt_or_t[n])) # state transition from n to n+1
89+
if A.ndim == 3: An = A[n] # state transition matrix from index n to n+1
10390
C_RTS = P_post[n] @ An.T @ np.linalg.inv(P_pre[n+1]) # the [n+1]th index holds _{n+1|n} info
10491
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
10592
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
10693

10794
return xhat_smooth if not compute_P_smooth else (xhat_smooth, P_smooth)
10895

10996

110-
def rtsdiff(x, dt_or_t, order, log_qr_ratio, forwardbackward):
97+
def rtsdiff(x, dt_or_t, order, log_qr_ratio, forwardbackward, axis=0):
11198
"""Perform Rauch-Tung-Striebel smoothing with a naive constant derivative model. Makes use of :code:`kalman_filter`
11299
and :code:`rts_smooth`, which are made public. :code:`constant_X` methods in this module call this function.
113100
114-
:param np.array[float] x: data series to differentiate. May contain NaN values (missing data); NaNs are excluded from
115-
fitting and imputed by dynamical model evolution.
101+
:param np.array[float] x: data series to differentiate. May contain NaN values (missing data); NaNs are excluded
102+
from fitting and imputed by dynamical model evolution. May be multidimensional; see :code:`axis`.
116103
:param float or array[float] dt_or_t: This function supports variable step size. This parameter is either the constant
117-
step size if given as a single float, or data locations if given as an array of same length as :code:`x`.
104+
:math:`\\Delta t` if given as a single float, or data locations if given as an array of same length as :code:`x`.
118105
:param int order: which derivative to stabilize in the constant-derivative model
119106
:param log_qr_ratio: the log of the process noise level divided by the measurement noise level, because,
120107
per `our analysis <https://github.com/florisvb/PyNumDiff/issues/139>`_, the mean result is
121108
dependent on the relative rather than absolute size of :math:`q` and :math:`r`.
122109
:param bool forwardbackward: indicates whether to run smoother forwards and backwards
123110
(usually achieves better estimate at end points)
111+
:param int axis: data dimension along which differentiation is performed
124112
125-
:return: - **x_hat** (np.array) -- estimated (smoothed) x
126-
- **dxdt_hat** (np.array) -- estimated derivative of x
113+
:return: - **x_hat** (np.array) -- estimated (smoothed) x, same shape as input :code:`x`
114+
- **dxdt_hat** (np.array) -- estimated derivative of x, same shape as input :code:`x`
127115
"""
128-
if not np.isscalar(dt_or_t) and len(x) != len(dt_or_t):
129-
raise ValueError("If `dt_or_t` is given as array-like, must have same length as `x`.")
130-
x = np.asarray(x) # to flexibly allow array-like inputs
116+
x = np.moveaxis(np.asarray(x), axis, 0) # move axis of differentiation to standard position
117+
if not np.isscalar(dt_or_t) and x.shape[0] != len(dt_or_t):
118+
raise ValueError("If `dt_or_t` is given as array-like, must have same length as x along `axis`.")
131119

132120
q = 10**int(log_qr_ratio/2) # even-ish split of the powers across 0
133121
r = q/(10**log_qr_ratio)
134-
135-
A = np.diag(np.ones(order), 1) # continuous-time A just has 1s on the first diagonal (where 0th is main diagonal)
136-
Q = np.zeros(A.shape); Q[-1,-1] = q # continuous-time uncertainty around the last derivative
122+
A_c = np.diag(np.ones(order), 1) # continuous-time A just has 1s on the first diagonal (where 0th is main diagonal)
123+
Q_c = np.zeros(A_c.shape); Q_c[-1,-1] = q # continuous-time uncertainty around the last derivative
137124
C = np.zeros((1, order+1)); C[0,0] = 1 # we measure only y = noisy x
138-
R = np.array([[r]]) # 1 observed state, so this is 1x1
139-
P0 = 100*np.eye(order+1) # See #110 for why this choice of P0
140-
xhat0 = np.zeros(A.shape[0]); xhat0[0] = x[0] # The first estimate is the first seen state. See #110
125+
R = np.array([[r]]); P0 = 100*np.eye(order+1) # See #110 for why this choice of P0
126+
M = np.block([[A_c, Q_c],[np.zeros(A_c.shape), -A_c.T]])
141127

128+
# Pre-compute discrete-time transition matrices once (shared across all dimensions)
142129
if np.isscalar(dt_or_t):
143-
eM = expm(np.block([[A, Q],[np.zeros(A.shape), -A.T]]) * dt_or_t) # form discrete-time versions
144-
A = eM[:order+1,:order+1]
145-
Q = eM[:order+1,order+1:] @ A.T
146-
147-
xhat_pre, xhat_post, P_pre, P_post = kalman_filter(x, dt_or_t, xhat0, P0, A, Q, C, R) # noisy x are the "y" in Kalman-land
148-
xhat_smooth = rts_smooth(dt_or_t, A, xhat_pre, xhat_post, P_pre, P_post, compute_P_smooth=False)
149-
x_hat_forward = xhat_smooth[:, 0] # first dimension is time, so slice first element at all times
150-
dxdt_hat_forward = xhat_smooth[:, 1]
130+
eM = expm(M * dt_or_t)
131+
A_d = eM[:order+1, :order+1]
132+
Q_d = eM[:order+1, order+1:] @ A_d.T
133+
if forwardbackward: A_d_bwd = np.linalg.inv(A_d)
134+
else:
135+
A_d = np.empty((len(x)-1, order+1, order+1))
136+
Q_d = np.empty_like(A_d)
137+
for i, dt in enumerate(np.diff(dt_or_t)):
138+
eM = expm(M * dt)
139+
A_d[i] = eM[:order+1, :order+1]
140+
Q_d[i] = eM[:order+1, order+1:] @ A_d[i].T
141+
if forwardbackward: A_d_bwd = np.linalg.inv(A_d[::-1]) # properly broadcasts, taking inv of each stacked 2D array
151142

152-
if not forwardbackward: # bounce out here if not doing the same in reverse and then combining
153-
return x_hat_forward, dxdt_hat_forward
143+
x_hat = np.empty_like(x); dxdt_hat = np.empty_like(x)
144+
if forwardbackward: w = np.linspace(0, 1, len(x)) # weights used to combine forward and backward results
154145

155-
xhat0[0] = x[-1] # starting from the other end of the signal
146+
for vec_idx in np.ndindex(x.shape[1:]):
147+
s = (slice(None),) + vec_idx # for indexing the the vector we wish to differentiate
148+
xhat0 = np.zeros(order+1); xhat0[0] = x[s][0] if not np.isnan(x[s][0]) else 0 # The first estimate is the first seen state. See #110
156149

157-
if np.isscalar(dt_or_t): A = np.linalg.inv(A) # discrete time dynamics are just the inverse
158-
else: dt_or_t = dt_or_t[::-1] # in continuous time, reverse the time vector so dts go negative
150+
xhat_pre, xhat_post, P_pre, P_post = kalman_filter(x[s], xhat0, P0, A_d, Q_d, C, R)
151+
xhat_smooth = rts_smooth(A_d, xhat_pre, xhat_post, P_pre, P_post, compute_P_smooth=False)
152+
x_hat[s] = xhat_smooth[:, 0] # first dimension is time, so slice first element at all times
153+
dxdt_hat[s] = xhat_smooth[:, 1]
159154

160-
xhat_pre, xhat_post, P_pre, P_post = kalman_filter(x[::-1], dt_or_t, xhat0, P0, A, Q, C, R)
161-
xhat_smooth = rts_smooth(dt_or_t, A, xhat_pre, xhat_post, P_pre, P_post, compute_P_smooth=False)
162-
x_hat_backward = xhat_smooth[:, 0][::-1] # the result is backwards still, so reverse it
163-
dxdt_hat_backward = xhat_smooth[:, 1][::-1]
155+
if forwardbackward:
156+
xhat0[0] = x[s][-1] if not np.isnan(x[s][-1]) else 0
157+
xhat_pre, xhat_post, P_pre, P_post = kalman_filter(x[s][::-1], xhat0, P0, A_d_bwd,
158+
Q_d if Q_d.ndim == 2 else Q_d[::-1], C, R) # Use same Q matrices as before, because noise should still grow in reverse time
159+
xhat_smooth = rts_smooth(A_d_bwd, xhat_pre, xhat_post, P_pre, P_post, compute_P_smooth=False)
164160

165-
w = np.linspace(0, 1, len(x))
166-
x_hat = x_hat_forward*w + x_hat_backward*(1-w)
167-
dxdt_hat = dxdt_hat_forward*w + dxdt_hat_backward*(1-w)
161+
x_hat[s] = x_hat[s] * w + xhat_smooth[:, 0][::-1] * (1-w)
162+
dxdt_hat[s] = dxdt_hat[s] * w + xhat_smooth[:, 1][::-1] * (1-w)
168163

169-
return x_hat, dxdt_hat
164+
return np.moveaxis(x_hat, 0, axis), np.moveaxis(dxdt_hat, 0, axis)
170165

171166

172167
def constant_velocity(x, dt, params=None, options=None, r=None, q=None, forwardbackward=True):
@@ -261,7 +256,7 @@ def constant_jerk(x, dt, params=None, options=None, r=None, q=None, forwardbackw
261256

262257
def robustdiff(x, dt_or_t, order, log_q, log_r, proc_huberM=6, meas_huberM=0):
263258
"""Perform outlier-robust differentiation by solving the Maximum A Priori optimization problem:
264-
:math:`\\text{argmin}_{\\{x_n\\}} \\sum_{n=0}^{N-1} V(R^{-1/2}(y_n - C x_n)) + \\sum_{n=1}^{N-1} J(Q^{-1/2}(x_n - A x_{n-1}))`,
259+
:math:`\\text{argmin}_{\\{x_n\\}} \\sum_{n=0}^{N-1} V(R^{-1/2}(y_n - C x_n)) + \\sum_{n=1}^{N-1} J(Q_{n-1}^{-1/2}(x_n - A_{n-1} x_{n-1}))`,
265260
where :math:`A,Q,C,R` come from an assumed constant derivative model and :math:`V,J` are the :math:`\\ell_1` norm or Huber
266261
loss rather than the :math:`\\ell_2` norm optimized by RTS smoothing. This problem is convex, so this method calls
267262
:code:`convex_smooth`, which in turn forms a sparse CVXPY problem and invokes CLARABEL.
@@ -285,7 +280,8 @@ def robustdiff(x, dt_or_t, order, log_q, log_r, proc_huberM=6, meas_huberM=0):
285280
:math:`r` interact with the distinct Huber :math:`M` parameters.
286281
287282
:param np.array[float] x: data series to differentiate
288-
:param float dt: step size
283+
:param float or array[float] dt_or_t: This function supports variable step size. This parameter is either the constant
284+
:math:`\\Delta t` if given as a single float, or data locations if given as an array of same length as :code:`x`.
289285
:param int order: which derivative to stabilize in the constant-derivative model (1=velocity, 2=acceleration, 3=jerk)
290286
:param float log_q: base 10 logarithm of process noise variance, so :code:`q = 10**log_q`
291287
:param float log_r: base 10 logarithm of measurement noise variance, so :code:`r = 10**log_r`
@@ -315,26 +311,26 @@ def robustdiff(x, dt_or_t, order, log_q, log_r, proc_huberM=6, meas_huberM=0):
315311
A_d = np.empty((len(x)-1, order+1, order+1)) # stack all the evolution matrices
316312
Q_d = np.empty((len(x)-1, order+1, order+1))
317313

318-
for i, dt in enumerate(np.diff(dt_or_t)): # for each variable time step
314+
for n,dt in enumerate(np.diff(dt_or_t)): # for each variable time step
319315
eM = expm(M * dt)
320-
A_d[i] = eM[:order+1, :order+1] # extract discrete time A matrix
321-
Q_d[i] = eM[:order+1, order+1:] @ A_d[i].T # extract discrete time Q matrix
322-
if np.linalg.cond(Q_d[i]) > 1e12: Q_d[i] += np.eye(order + 1)*1e-12
316+
A_d[n] = eM[:order+1, :order+1] # extract discrete time A matrix
317+
Q_d[n] = eM[:order+1, order+1:] @ A_d[n].T # extract discrete time Q matrix
318+
if np.linalg.cond(Q_d[n]) > 1e12: Q_d[n] += np.eye(order + 1)*1e-12
323319

324320
x_states = convex_smooth(x, A_d, Q_d, C, R, proc_huberM=proc_huberM, meas_huberM=meas_huberM) # outsource solution of the convex optimization problem
325321
return x_states[:,0], x_states[:,1]
326322

327323

328324
def convex_smooth(y, A, Q, C, R, B=None, u=None, proc_huberM=6, meas_huberM=0):
329-
"""Solve the optimization problem for robust smoothing using CVXPY. Note this currently assumes constant dt
330-
but could be extended to handle variable step sizes by finding discrete-time A and Q for requisite gaps.
325+
"""Solve the optimization problem for robust smoothing using CVXPY.
331326
332327
:param np.array[float] y: measurements
333-
:param np.array A: discrete-time state transition matrix
334-
:param np.array Q: discrete-time process noise covariance matrix
328+
:param np.array A: discrete-time state transition matrix. If 2D (:math:`m \\times m`), the same matrix is used for all steps;
329+
if 3D (:math:`N-1 \\times m \\times m`), :code:`A[n-1]` is used for the transition from step :math:`n-1` to :math:`n`.
330+
:param np.array Q: discrete-time process noise covariance. Same 2D or 3D shape convention as :code:`A`.
335331
:param np.array C: measurement matrix
336332
:param np.array R: measurement noise covariance matrix
337-
:param np.array B: optional control matrix
333+
:param np.array B: optional discrete-time control matrix, optionally stacked like :code:`A`.
338334
:param np.array u: optional control inputs, stacked in the direction of axis 0
339335
:param float proc_huberM: Huber loss parameter. :math:`M=0` reduces to :math:`\\sqrt{2}\\ell_1`.
340336
:param float meas_huberM: Huber loss parameter. :math:`M=\\infty` reduces to :math:`\\frac{1}{2}\\ell_2^2`.
@@ -349,7 +345,8 @@ def convex_smooth(y, A, Q, C, R, B=None, u=None, proc_huberM=6, meas_huberM=0):
349345
if A.ndim == 3: # It is extremely important to runtime that CVXPY expressions be in vectorized form
350346
Ax = cvxpy.einsum('nij,jn->in', A, x_states[:, :-1]) # multipy each A matrix by the corresponding x_states at that time step
351347
Q_inv_sqrts = np.array([np.linalg.inv(sqrtm(Q[n])) for n in range(N-1)]) # precompute Q^(-1/2) for each time step
352-
proc_resids = cvxpy.einsum('nij,jn->in', Q_inv_sqrts, x_states[:,1:] - Ax - (0 if not control else B @ u[1:].T))
348+
Bu = 0 if not control else cvxpy.einsum('nij,nj->in', B, u[1:]) if B.ndim == 3 else B @ u[1:].T
349+
proc_resids = cvxpy.einsum('nij,jn->in', Q_inv_sqrts, x_states[:,1:] - Ax - Bu)
353350
else: # all Q^(-1/2)(x_n - (A x_{n-1} + B u_n))
354351
proc_resids = np.linalg.inv(sqrtm(Q)) @ (x_states[:,1:] - A @ x_states[:,:-1] - (0 if not control else B @ u[1:].T))
355352

0 commit comments

Comments
 (0)