Skip to content

Commit 7e365f8

Browse files
committed
Merge branch 'master' of github.com:florisvb/PyNumDiff into spectraldiff-multidim
2 parents 4192a6c + 587a78f commit 7e365f8

3 files changed

Lines changed: 75 additions & 53 deletions

File tree

pynumdiff/kalman_smooth.py

Lines changed: 36 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,43 @@ 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+
321+
for vec_idx in np.ndindex(x.shape[:axis] + x.shape[axis+1:]): # works properly for 1D case too
322+
s = vec_idx[:axis] + (slice(None),) + vec_idx[axis:]
323+
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
324+
x_hat[s] = x_states[:,0]; dxdt_hat[s] = x_states[:,1]
319325

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]
326+
return x_hat, dxdt_hat
322327

323328

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

pynumdiff/polynomial_fit.py

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pynumdiff.utils import utility
77

88

9-
def splinediff(x, dt_or_t, params=None, options=None, degree=3, s=None, num_iterations=1):
9+
def splinediff(x, dt_or_t, params=None, options=None, degree=3, s=None, num_iterations=1, axis=0):
1010
"""Find smoothed data and derivative estimates by fitting a smoothing spline to the data with
1111
scipy.interpolate.UnivariateSpline. Variable step size is supported with equal ease as uniform step size.
1212
@@ -20,6 +20,7 @@ def splinediff(x, dt_or_t, params=None, options=None, degree=3, s=None, num_iter
2020
:param float s: positive smoothing factor used to choose the number of knots. Number of knots will be increased
2121
until the smoothing condition is satisfied: :math:`\\sum_t (x[t] - \\text{spline}[t])^2 \\leq s`
2222
:param int num_iterations: how many times to apply smoothing
23+
:param int axis: data dimension along which differentiation is performed
2324
2425
:return: - **x_hat** (np.array) -- estimated (smoothed) x
2526
- **dxdt_hat** (np.array) -- estimated derivative of x
@@ -31,26 +32,29 @@ def splinediff(x, dt_or_t, params=None, options=None, degree=3, s=None, num_iter
3132
if options is not None:
3233
if 'iterate' in options and options['iterate']: num_iterations = params[2]
3334

35+
if num_iterations < 1: raise ValueError("`num_iterations` should be >=1")
3436
if np.isscalar(dt_or_t):
35-
t = np.arange(len(x))*dt_or_t
37+
t = np.arange(x.shape[axis]) * dt_or_t
3638
else: # support variable step size for this function
37-
if len(x) != len(dt_or_t): raise ValueError("If `dt_or_t` is given as array-like, must have same length as `x`.")
39+
if x.shape[axis] != len(dt_or_t): raise ValueError("If `dt_or_t` is given as array-like, must have same length as `x`.")
3840
t = dt_or_t
3941

40-
x_hat = x
41-
for _ in range(num_iterations):
42-
obs = ~np.isnan(x_hat) # UnivariateSpline can't handle NaN, so fit only on observed points
43-
spline = scipy.interpolate.UnivariateSpline(t[obs], x_hat[obs], k=degree, s=s)
44-
x_hat = spline(t) # evaluate at all t, filling in NaN positions by interpolation
42+
x_hat = np.empty_like(x); dxdt_hat = np.empty_like(x)
43+
44+
for vec_idx in np.ndindex(x.shape[:axis] + x.shape[axis+1:]):
45+
i = vec_idx[:axis] + (slice(None),) + vec_idx[axis:] # use i instead of s, becase s is already used as smoothness param
4546

46-
dspline = spline.derivative()
47-
dxdt_hat = dspline(t)
47+
obs = ~np.isnan(x[i]) # make_splrep can't handle NaN, so use only observed points for first fit
48+
x_hat[i] = scipy.interpolate.make_splrep(t[obs], x[i][obs], k=degree, s=s)(t) # interpolate at all t
49+
for _ in range(num_iterations-1):
50+
x_hat[i] = scipy.interpolate.make_splrep(t, x_hat[i], k=degree, s=s)(t)
51+
dxdt_hat[i] = spline.derivative()(t) # evaluate derivative at sample points
4852

4953
return x_hat, dxdt_hat
5054

5155

5256
def polydiff(x, dt_or_t, params=None, options=None, degree=None, window_size=None, step_size=1,
53-
kernel='friedrichs'):
57+
kernel='friedrichs', axis=0):
5458
"""Fit polynomials to the data, and differentiate the polynomials.
5559
5660
:param np.array[float] x: data to differentiate. May contain NaN values (missing data); NaNs are excluded from
@@ -64,6 +68,7 @@ def polydiff(x, dt_or_t, params=None, options=None, degree=None, window_size=Non
6468
:param int window_size: size of the sliding window, if not given no sliding
6569
:param int step_size: step size for sliding
6670
:param str kernel: name of kernel to use for weighting and smoothing windows ('gaussian' or 'friedrichs')
71+
:param int axis: data dimension along which differentiation is performed
6772
6873
:return: - **x_hat** (np.array) -- estimated (smoothed) x
6974
- **dxdt_hat** (np.array) -- estimated derivative of x
@@ -80,11 +85,13 @@ def polydiff(x, dt_or_t, params=None, options=None, degree=None, window_size=Non
8085
elif degree is None or window_size is None:
8186
raise ValueError("`degree` and `window_size` must be given.")
8287

83-
if window_size < degree*3:
84-
window_size = degree*3+1
85-
if window_size % 2 == 0:
86-
window_size += 1
87-
warn("Kernel window size should be odd. Added 1 to length.")
88+
if window_size:
89+
if window_size < degree*3:
90+
window_size = degree*3+1
91+
if window_size % 2 == 0:
92+
window_size += 1
93+
warn("Kernel window size should be odd. Added 1 to length.")
94+
kernel = {'gaussian':utility.gaussian_kernel, 'friedrichs':utility.friedrichs_kernel}[kernel](window_size)
8895

8996
def _polydiff(x, dt_or_t, degree, weights=None):
9097
t = dt_or_t if not np.isscalar(dt_or_t) else np.arange(len(x)) * dt_or_t # sample locations
@@ -99,10 +106,14 @@ def _polydiff(x, dt_or_t, degree, weights=None):
99106

100107
return x_hat, dxdt_hat
101108

102-
if not window_size: return _polydiff(x, dt_or_t, degree)
109+
x_hat = np.empty_like(x); dxdt_hat = np.empty_like(x)
103110

104-
kernel = {'gaussian':utility.gaussian_kernel, 'friedrichs':utility.friedrichs_kernel}[kernel](window_size)
105-
return utility.slide_function(_polydiff, x, dt_or_t, kernel, degree, stride=step_size, pass_weights=True)
111+
for vec_idx in np.ndindex(x.shape[:axis] + x.shape[axis+1:]):
112+
s = vec_idx[:axis] + (slice(None),) + vec_idx[axis:]
113+
x_hat[s], dxdt_hat[s] = _polydiff(x[s], dt_or_t, degree) if not window_size else \
114+
utility.slide_function(_polydiff, x[s], dt_or_t, kernel, degree, stride=step_size, pass_weights=True)
115+
116+
return x_hat, dxdt_hat
106117

107118

108119
def savgoldiff(x, dt, params=None, options=None, degree=None, window_size=None, smoothing_win=None, axis=0):

pynumdiff/tests/test_diff_methods.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ def polydiff_irreg_step(*args, **kwargs): return polydiff(*args, **kwargs)
149149
[(0, -1), (0, 0), (0, 0), (1, 0)],
150150
[(1, 1), (2, 2), (1, 1), (2, 2)],
151151
[(1, 1), (3, 3), (1, 1), (3, 3)]],
152-
splinediff: [[(-14, -15), (-14, -15), (-1, -1), (0, 0)],
152+
splinediff: [[(-14, -14), (-14, -14), (-1, -1), (0, 0)],
153153
[(-14, -14), (-13, -14), (-1, -1), (0, 0)],
154154
[(-14, -14), (-13, -13), (-1, -1), (0, 0)],
155155
[(0, 0), (1, 1), (0, 0), (1, 1)],
@@ -325,8 +325,11 @@ def test_diff_method(diff_method_and_params, test_func_and_deriv, request): # re
325325
(kerneldiff, {'kernel': 'gaussian', 'window_size': 5}),
326326
(butterdiff, {'filter_order': 3, 'cutoff_freq': 1 - 1e-6}),
327327
(finitediff, {}),
328+
(polydiff, {'degree': 2, 'window_size': 5}),
328329
(savgoldiff, {'degree': 3, 'window_size': 11, 'smoothing_win': 3}),
329-
(rtsdiff, {'order':2, 'log_qr_ratio':7, 'forwardbackward':True})
330+
(rtsdiff, {'order':2, 'log_qr_ratio':7, 'forwardbackward':True}),
331+
(splinediff, {'degree': 9, 's': 1e-6}),
332+
(robustdiff, {'order':2, 'log_q':7, 'log_r':2})
330333
]
331334

332335
# Similar to the error_bounds table, index by method first. But then we test against only one 2D function,
@@ -337,8 +340,11 @@ def test_diff_method(diff_method_and_params, test_func_and_deriv, request): # re
337340
kerneldiff: [(2, 1), (3, 2)],
338341
butterdiff: [(0, -1), (1, -1)],
339342
finitediff: [(0, -1), (1, -1)],
343+
polydiff: [(1, -1), (1, 0)],
340344
savgoldiff: [(0, -1), (1, 1)],
341-
rtsdiff: [(1, -1), (1, 0)]
345+
rtsdiff: [(1, -1), (1, 0)],
346+
splinediff: [(0, -1), (1, 0)],
347+
robustdiff: [(-2, -3), (0, -1)]
342348
}
343349

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

0 commit comments

Comments
 (0)