Skip to content

Commit 214b3e2

Browse files
authored
Merge pull request #195 from florisvb/robust-mutlidim
Add axis parameter to robustdiff (#76)
2 parents 4755e00 + 31b5dab commit 214b3e2

2 files changed

Lines changed: 39 additions & 33 deletions

File tree

pynumdiff/kalman_smooth.py

Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ def kalman_filter(y, xhat0, P0, A, Q, C, R, B=None, u=None, save_P=True):
3737
m = xhat0.shape[0] # dimension of the state
3838
xhat_post = np.empty((N,m))
3939
if save_P:
40-
xhat_pre = np.empty((N,m)) # _pre = a priori predictions based on only past information
40+
xhat_pre = np.empty_like(xhat_post) # _pre = a priori predictions based on only past information
4141
P_pre = np.empty((N,m,m)) # _post = a posteriori combinations of all information available at a step
42-
P_post = np.empty((N,m,m))
42+
P_post = np.empty_like(P_pre)
4343

4444
control = isinstance(B, np.ndarray) and isinstance(u, np.ndarray) # whether there is a control input
4545
if A.ndim == 2: An, Qn, Bn = A, Q, B # single matrices, assign once outside the loop
@@ -113,8 +113,8 @@ def rtsdiff(x, dt_or_t, order, log_qr_ratio, forwardbackward, axis=0):
113113
:return: - **x_hat** (np.array) -- estimated (smoothed) x, same shape as input :code:`x`
114114
- **dxdt_hat** (np.array) -- estimated derivative of x, same shape as input :code:`x`
115115
"""
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):
116+
N = x.shape[axis]
117+
if not np.isscalar(dt_or_t) and N != len(dt_or_t):
118118
raise ValueError("If `dt_or_t` is given as array-like, must have same length as x along `axis`.")
119119

120120
q = 10**int(log_qr_ratio/2) # even-ish split of the powers across 0
@@ -132,25 +132,25 @@ def rtsdiff(x, dt_or_t, order, log_qr_ratio, forwardbackward, axis=0):
132132
Q_d = eM[:order+1, order+1:] @ A_d.T
133133
if forwardbackward: A_d_bwd = np.linalg.inv(A_d)
134134
else:
135-
A_d = np.empty((len(x)-1, order+1, order+1))
135+
A_d = np.empty((N-1, order+1, order+1))
136136
Q_d = np.empty_like(A_d)
137-
for i, dt in enumerate(np.diff(dt_or_t)):
137+
for n,dt in enumerate(np.diff(dt_or_t)):
138138
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
139+
A_d[n] = eM[:order+1, :order+1]
140+
Q_d[n] = eM[:order+1, order+1:] @ A_d[n].T
141141
if forwardbackward: A_d_bwd = np.linalg.inv(A_d[::-1]) # properly broadcasts, taking inv of each stacked 2D array
142142

143143
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
144+
if forwardbackward: w = np.linspace(0, 1, N) # weights used to combine forward and backward results
145145

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
146+
for vec_idx in np.ndindex(x.shape[:axis] + x.shape[axis+1:]): # works properly for 1D case too
147+
s = vec_idx[:axis] + (slice(None),) + vec_idx[axis:] # for indexing the vector we wish to differentiate
148148
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
149149

150150
xhat_pre, xhat_post, P_pre, P_post = kalman_filter(x[s], xhat0, P0, A_d, Q_d, C, R)
151151
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]
152+
x_hat[s] = xhat_smooth[:,0] # first dimension is time, so slice first and second states at all times
153+
dxdt_hat[s] = xhat_smooth[:,1]
154154

155155
if forwardbackward:
156156
xhat0[0] = x[s][-1] if not np.isnan(x[s][-1]) else 0
@@ -161,7 +161,7 @@ def rtsdiff(x, dt_or_t, order, log_qr_ratio, forwardbackward, axis=0):
161161
x_hat[s] = x_hat[s] * w + xhat_smooth[:, 0][::-1] * (1-w)
162162
dxdt_hat[s] = dxdt_hat[s] * w + xhat_smooth[:, 1][::-1] * (1-w)
163163

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

166166

167167
def constant_velocity(x, dt, params=None, options=None, r=None, q=None, forwardbackward=True):
@@ -254,7 +254,7 @@ def constant_jerk(x, dt, params=None, options=None, r=None, q=None, forwardbackw
254254
return rtsdiff(x, dt, 3, np.log10(q/r), forwardbackward)
255255

256256

257-
def robustdiff(x, dt_or_t, order, log_q, log_r, proc_huberM=6, meas_huberM=0):
257+
def robustdiff(x, dt_or_t, order, log_q, log_r, proc_huberM=6, meas_huberM=0, axis=0):
258258
"""Perform outlier-robust differentiation by solving the Maximum A Priori optimization problem:
259259
: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}))`,
260260
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
@@ -287,38 +287,42 @@ def robustdiff(x, dt_or_t, order, log_q, log_r, proc_huberM=6, meas_huberM=0):
287287
:param float log_r: base 10 logarithm of measurement noise variance, so :code:`r = 10**log_r`
288288
:param float proc_huberM: quadratic-to-linear transition point for process loss
289289
:param float meas_huberM: quadratic-to-linear transition point for measurement loss
290+
:param int axis: data dimension along which differentiation is performed
290291
291-
:return: - **x_hat** (np.array) -- estimated (smoothed) x
292-
- **dxdt_hat** (np.array) -- estimated derivative of x
292+
:return: - **x_hat** (np.array) -- estimated (smoothed) x, same shape as input :code:`x`
293+
- **dxdt_hat** (np.array) -- estimated derivative of x, same shape as input :code:`x`
293294
"""
294-
equispaced = np.isscalar(dt_or_t)
295-
if not equispaced and len(x) != len(dt_or_t):
296-
raise ValueError("If `dt_or_t` is given as array-like, must have same length as `x`.")
295+
N = x.shape[axis]
296+
if not np.isscalar(dt_or_t) and N != len(dt_or_t):
297+
raise ValueError("If `dt_or_t` is given as array-like, must have same length as `x` along `axis`.")
297298

298299
A_c = np.diag(np.ones(order), 1) # continuous-time A just has 1s on the first diagonal (where 0th is main diagonal)
299300
Q_c = np.zeros(A_c.shape); Q_c[-1,-1] = 10**log_q # continuous-time uncertainty around the last derivative
300301
C = np.zeros((1, order+1)); C[0,0] = 1 # we measure only y = noisy x
301302
R = np.array([[10**log_r]]) # 1 observed state, so this is 1x1
302303
M = np.block([[A_c, Q_c], [np.zeros(A_c.shape), -A_c.T]]) # exponentiate per step
303304

304-
if equispaced:
305-
# convert to discrete time using matrix exponential
306-
eM = expm(M * dt_or_t) # Note this could handle variable dt, similar to rtsdiff
305+
if np.isscalar(dt_or_t): # convert to discrete time using matrix exponential
306+
eM = expm(M * dt_or_t)
307307
A_d = eM[:order+1, :order+1]
308308
Q_d = eM[:order+1, order+1:] @ A_d.T
309-
if np.linalg.cond(Q_d) > 1e12: Q_d += np.eye(order + 1)*1e-12 # for numerical stability with convex solver. Doesn't change answers appreciably (or at all).
309+
if np.linalg.cond(Q_d) > 1e12: Q_d += np.eye(order+1)*1e-12 # for numerical stability with convex solver. Doesn't change answers appreciably (or at all).
310310
else: # support variable step size for this function
311-
A_d = np.empty((len(x)-1, order+1, order+1)) # stack all the evolution matrices
312-
Q_d = np.empty((len(x)-1, order+1, order+1))
313-
314-
for n,dt in enumerate(np.diff(dt_or_t)): # for each variable time step
311+
A_d = np.empty((N-1, order+1, order+1))
312+
Q_d = np.empty_like(A_d)
313+
for n,dt in enumerate(np.diff(dt_or_t)):
315314
eM = expm(M * dt)
316315
A_d[n] = eM[:order+1, :order+1] # extract discrete time A matrix
317316
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
317+
if np.linalg.cond(Q_d[n]) > 1e12: Q_d[n] += np.eye(order+1)*1e-12
318+
319+
x_hat = np.empty_like(x); dxdt_hat = np.empty_like(x)
320+
for vec_idx in np.ndindex(x.shape[:axis] + x.shape[axis+1:]): # works properly for 1D case too
321+
s = vec_idx[:axis] + (slice(None),) + vec_idx[axis:]
322+
x_states = convex_smooth(x[s], A_d, Q_d, C, R, proc_huberM=proc_huberM, meas_huberM=meas_huberM) # outsource solution of the convex optimization problem
323+
x_hat[s] = x_states[:,0]; dxdt_hat[s] = x_states[:,1]
319324

320-
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
321-
return x_states[:,0], x_states[:,1]
325+
return x_hat, dxdt_hat
322326

323327

324328
def convex_smooth(y, A, Q, C, R, B=None, u=None, proc_huberM=6, meas_huberM=0):

pynumdiff/tests/test_diff_methods.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,8 @@ def test_diff_method(diff_method_and_params, test_func_and_deriv, request): # re
326326
(butterdiff, {'filter_order': 3, 'cutoff_freq': 1 - 1e-6}),
327327
(finitediff, {}),
328328
(savgoldiff, {'degree': 3, 'window_size': 11, 'smoothing_win': 3}),
329-
(rtsdiff, {'order':2, 'log_qr_ratio':7, 'forwardbackward':True})
329+
(rtsdiff, {'order':2, 'log_qr_ratio':7, 'forwardbackward':True}),
330+
(robustdiff, {'order':2, 'log_q':7, 'log_r':2}),
330331
]
331332

332333
# Similar to the error_bounds table, index by method first. But then we test against only one 2D function,
@@ -338,7 +339,8 @@ def test_diff_method(diff_method_and_params, test_func_and_deriv, request): # re
338339
butterdiff: [(0, -1), (1, -1)],
339340
finitediff: [(0, -1), (1, -1)],
340341
savgoldiff: [(0, -1), (1, 1)],
341-
rtsdiff: [(1, -1), (1, 0)]
342+
rtsdiff: [(1, -1), (1, 0)],
343+
robustdiff: [(-2, -3), (0, -1)]
342344
}
343345

344346
@mark.parametrize("multidim_method_and_params", multidim_methods_and_params)

0 commit comments

Comments
 (0)