88from 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
172167def 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
262257def 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
328324def 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