Skip to content

Commit e4de566

Browse files
authored
Merge pull request #149 from florisvb/add-rbfdiff
Making the house even bigger
2 parents 18437b2 + d3ef394 commit e4de566

5 files changed

Lines changed: 76 additions & 24 deletions

File tree

pynumdiff/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
from .polynomial_fit import splinediff, polydiff, savgoldiff
88
from .total_variation_regularization import tvrdiff, velocity, acceleration, jerk, iterative_velocity, smooth_acceleration, jerk_sliding
99
from .kalman_smooth import rtsdiff, constant_velocity, constant_acceleration, constant_jerk, known_dynamics
10-
from .linear_model import spectraldiff, lineardiff
10+
from .linear_model import spectraldiff, lineardiff, rbfdiff

pynumdiff/linear_model/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
warn("Limited Linear Model Support Detected! CVXPY is not installed. " +
99
"Install CVXPY to use lineardiff derivatives. You can still use other methods.")
1010

11-
from ._linear_model import savgoldiff, polydiff, spectraldiff
11+
from ._linear_model import savgoldiff, polydiff, spectraldiff, rbfdiff
1212

13-
__all__ = ['lineardiff', 'spectraldiff'] # So these get treated as direct members of the module by sphinx
14-
# polydiff and savgoldiff are still imported for now for backwards compatibility but are not
15-
# documented as part of this module, since they've moved
13+
__all__ = ['lineardiff', 'spectraldiff', 'rbfdiff'] # So these get treated as direct members of the
14+
# module by sphinx polydiff and savgoldiff are still imported for now for backwards compatibility
15+
# but are not documented as part of this module, since they've moved

pynumdiff/linear_model/_linear_model.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import math, scipy
22
import numpy as np
3+
from scipy import sparse
34
from warnings import warn
45

56
from pynumdiff.finite_difference import second_order as finite_difference
@@ -22,9 +23,6 @@ def polydiff(*args, **kwargs):
2223
return _polydiff(*args, **kwargs)
2324

2425

25-
###############
26-
# Linear diff #
27-
###############
2826
def _solve_for_A_and_C_given_X_and_Xdot(X, Xdot, num_integrations, dt, gammaC=1e-1, gammaA=1e-6,
2927
solver=None, A_known=None, epsilon=1e-6):
3028
"""Given state and the derivative, find the system evolution and measurement matrices."""
@@ -161,9 +159,6 @@ def _lineardiff(x, dt, order, gamma, solver=None):
161159
return x_hat, dxdt_hat
162160

163161

164-
###############################
165-
# Fourier Spectral derivative #
166-
###############################
167162
def spectraldiff(x, dt, params=None, options=None, high_freq_cutoff=None, even_extension=True, pad_to_zero_dxdt=True):
168163
"""Take a derivative in the fourier domain, with high frequency attentuation.
169164
@@ -233,3 +228,53 @@ def spectraldiff(x, dt, params=None, options=None, high_freq_cutoff=None, even_e
233228
x_hat = x_hat + x0
234229

235230
return x_hat, dxdt_hat
231+
232+
233+
def rbfdiff(x, _t, sigma=1, lmbd=0.01):
234+
"""Find smoothed function and derivative estimates by fitting noisy data against a radial-basis-function-filled
235+
kernel (naively NxN but made sparse by truncating tiny values). This function uses a Gaussian kernel, thereby
236+
assuming the signal is generated from a Gaussian process, effectively the same assumption the Kalman filter makes.
237+
It can handle variable step size just as easily as uniform step size.
238+
239+
:param np.array[float] x: data to differentiate
240+
: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`.
242+
:param float sigma: controls width of radial basis function
243+
:param float lmbd: controls strength of bias toward data
244+
245+
:return: tuple[np.array, np.array] of\n
246+
- **x_hat** -- estimated (smoothed) x
247+
- **dxdt_hat** -- estimated derivative of x
248+
"""
249+
if isinstance(_t, (np.ndarray, list)): # support variable step size for this function
250+
if len(x) != len(_t): raise ValueError("If `_t` is given as array-like, must have same length as `x`.")
251+
t = _t
252+
else:
253+
t = np.arange(len(x))*_t
254+
255+
# The below does the approximate equivalent of this code, but sparsely in O(N sigma), since the rbf falls off rapidly
256+
# t_i, t_j = np.meshgrid(t,t)
257+
# r = t_j - t_i # radius
258+
# rbf = np.exp(-(r**2) / (2 * sigma**2)) # radial basis function kernel
259+
# drbfdt = -(r / sigma**2) * rbf # derivative of kernel
260+
# rbf_regularized = rbf + lmbd*np.eye(len(t))
261+
# alpha = np.linalg.solve(rbf_regularized, x)
262+
263+
cutoff = np.sqrt(-2 * sigma**2 * np.log(1e-4))
264+
rows, cols, vals, dvals = [], [], [], []
265+
for n in range(len(t)):
266+
# Only consider points within a cutoff. Gaussian drops below eps at distance ~ sqrt(-2*sigma^2 log eps)
267+
l = np.searchsorted(t, t[n] - cutoff) # O(log N) to find indices of points within cutoff
268+
r = np.searchsorted(t, t[n] + cutoff) # finds index where new value should be inserted
269+
for j in range(l, r): # width of this is dependent on sigma. [l, r) is correct inclusion/exclusion
270+
radius = t[n] - t[j]
271+
v = np.exp(-radius**2 / (2 * sigma**2))
272+
dv = -radius / sigma**2 * v
273+
rows.append(n); cols.append(j); vals.append(v); dvals.append(dv)
274+
275+
rbf = sparse.csr_matrix((vals, (rows, cols)), shape=(len(t), len(t))) # Build sparse kernels
276+
drbfdt = sparse.csr_matrix((dvals, (rows, cols)), shape=(len(t), len(t)))
277+
rbf_regularized = rbf + lmbd*sparse.eye(len(t), format="csr")
278+
alpha = sparse.linalg.spsolve(rbf_regularized, x) # solve sparse system
279+
280+
return rbf @ alpha, drbfdt @ alpha

pynumdiff/polynomial_fit/_polynomial_fit.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
from pynumdiff.utils import utility
66

77

8-
def splinediff(x, dt, params=None, options={}, degree=3, s=None, num_iterations=1):
8+
def splinediff(x, _t, params=None, options={}, degree=3, s=None, num_iterations=1):
99
"""Find smoothed data and derivative estimates by fitting a smoothing spline to the data with
10-
scipy.interpolate.UnivariateSpline.
10+
scipy.interpolate.UnivariateSpline. Variable step size is supported with equal ease as uniform step size.
1111
1212
:param np.array[float] x: data to differentiate
13-
:param float or array[float] dt: 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`.
13+
: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`.
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.
@@ -30,11 +30,11 @@ def splinediff(x, dt, params=None, options={}, degree=3, s=None, num_iterations=
3030
if 'iterate' in options and options['iterate']:
3131
num_iterations = params[2]
3232

33-
if isinstance(dt, (np.ndarray, list)): # support variable step size for this function
34-
if len(x) != len(dt): raise ValueError("If `dt` is given as array-like, must have same length as `x`.")
35-
t = dt
33+
if isinstance(_t, (np.ndarray, list)): # support variable step size for this function
34+
if len(x) != len(_t): raise ValueError("If `_t` is given as array-like, must have same length as `x`.")
35+
t = _t
3636
else:
37-
t = np.arange(len(x))*dt
37+
t = np.arange(len(x))*_t
3838

3939
x_hat = x
4040
for _ in range(num_iterations):

pynumdiff/tests/test_diff_methods.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from warnings import warn
44

55
from ..finite_difference import first_order, second_order, fourth_order
6-
from ..linear_model import lineardiff, spectraldiff
6+
from ..linear_model import lineardiff, spectraldiff, rbfdiff
77
from ..polynomial_fit import polydiff, savgoldiff, splinediff
88
from ..total_variation_regularization import velocity, acceleration, jerk, iterative_velocity, smooth_acceleration, jerk_sliding
99
from ..kalman_smooth import constant_velocity, constant_acceleration, constant_jerk
@@ -54,7 +54,8 @@ def spline_irreg_step(*args, **kwargs): return splinediff(*args, **kwargs)
5454
(smooth_acceleration, {'gamma':2, 'window_size':5}), (smooth_acceleration, [2, 5]),
5555
(jerk_sliding, {'gamma':1, 'window_size':15}), (jerk_sliding, [1], {'window_size':15}),
5656
(spectraldiff, {'high_freq_cutoff':0.2}), (spectraldiff, [0.2]),
57-
(lineardiff, {'order':3, 'gamma':5, 'window_size':11, 'solver':'CLARABEL'}), (lineardiff, [3, 5, 11], {'solver':'CLARABEL'})
57+
(lineardiff, {'order':3, 'gamma':5, 'window_size':11, 'solver':'CLARABEL'}), (lineardiff, [3, 5, 11], {'solver':'CLARABEL'}),
58+
(rbfdiff, {'sigma':0.5, 'lmbd':0.001})
5859
]
5960

6061
# All the testing methodology follows the exact same pattern; the only thing that changes is the
@@ -212,7 +213,13 @@ def spline_irreg_step(*args, **kwargs): return splinediff(*args, **kwargs)
212213
[(1, 0), (2, 2), (1, 0), (2, 2)],
213214
[(1, 0), (2, 1), (1, 0), (2, 1)],
214215
[(1, 1), (2, 2), (1, 1), (2, 2)],
215-
[(1, 1), (3, 3), (1, 1), (3, 3)]]
216+
[(1, 1), (3, 3), (1, 1), (3, 3)]],
217+
rbfdiff: [[(-2, -2), (0, 0), (0, -1), (0, 0)],
218+
[(-1, -1), (1, 0), (0, -1), (1, 1)],
219+
[(-1, -1), (1, 1), (0, -1), (1, 1)],
220+
[(-2, -2), (0, 0), (0, -1), (0, 0)],
221+
[(0, 0), (2, 2), (0, 0), (2, 2)],
222+
[(1, 1), (3, 3), (1, 1), (3, 3)]]
216223
}
217224

218225
# Essentially run the cartesian product of [diff methods] x [test functions] through this one test
@@ -231,7 +238,7 @@ def test_diff_method(diff_method_and_params, test_func_and_deriv, request): # re
231238
except: warn(f"Cannot import cvxpy, skipping {diff_method} test."); return
232239

233240
# sample the true function and true derivative, and make noisy samples
234-
if diff_method in [spline_irreg_step]: # list that can handle variable dt
241+
if diff_method in [spline_irreg_step, rbfdiff]: # list that can handle variable dt
235242
x = f(t_irreg)
236243
dxdt = df(t_irreg)
237244
_t = t_irreg
@@ -252,7 +259,7 @@ def test_diff_method(diff_method_and_params, test_func_and_deriv, request): # re
252259
# plotting code
253260
if request.config.getoption("--plot") and not isinstance(params, list): # Get the plot flag from pytest configuration
254261
fig, axes = request.config.plots[diff_method] # get the appropriate plot, set up by the store_plots fixture in conftest.py
255-
t_ = t_irreg if diff_method in [spline_irreg_step] else t
262+
t_ = t_irreg if diff_method in [spline_irreg_step, rbfdiff] else t
256263
axes[i, 0].plot(t_, f(t_))
257264
axes[i, 0].plot(t_, x, 'C0+')
258265
axes[i, 0].plot(t_, x_hat, 'C2.', ms=4)

0 commit comments

Comments
 (0)