Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ PyNumDiff is a Python package that implements various methods for computing nume
3. iterated finite differencing
4. total variation regularization of a finite difference derivative
5. Kalman (RTS) smoothing
6. Fourier spectral with tricks
6. basis-function-based methods
7. linear local approximation with linear model

Most of these methods have multiple parameters, so we take a principled approach and propose a multi-objective optimization framework for choosing parameters that minimize a loss function to balance the faithfulness and smoothness of the derivative estimate. For more details, refer to [this paper](https://doi.org/10.1109/ACCESS.2020.3034077).
Expand All @@ -47,7 +47,7 @@ For more details, read our [Sphinx documentation](https://pynumdiff.readthedocs.
somethingdiff(x, dt, **kwargs)
```

where `x` is data, `dt` is a step size, and various keyword arguments control the behavior. Some methods support variable step size, in which case `dt` can also receive an array of values to denote sample locations.
where `x` is data, `dt` is a step size, and various keyword arguments control the behavior. Some methods support variable step size, in which case the second parameter is renamed `_t` and can receive either a constant step size or an array of values to denote sample locations.

You can provide the parameters:
```python
Expand Down
5 changes: 5 additions & 0 deletions docs/source/basis_fit.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
basis_fit
=========

.. automodule:: pynumdiff.basis_fit
:members:
52 changes: 41 additions & 11 deletions examples/1_basic_tutorial.ipynb

Large diffs are not rendered by default.

54 changes: 48 additions & 6 deletions examples/2a_optimizing_parameters_with_dxdt_known.ipynb

Large diffs are not rendered by default.

54 changes: 48 additions & 6 deletions examples/2b_optimizing_parameters_with_dxdt_unknown.ipynb

Large diffs are not rendered by default.

11 changes: 2 additions & 9 deletions examples/3_automatic_method_suggestion.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
"name": "stderr",
"output_type": "stream",
"text": [
"100%|██████████| 13/13 [01:56<00:00, 8.96s/it]\n"
"100%|██████████| 14/14 [01:57<00:00, 8.39s/it]\n"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly got faster due to #154. I'm seeing polydiff takes 30-35 seconds to optimize now. It was formerly nearly a minute.

]
}
],
Expand Down Expand Up @@ -145,14 +145,7 @@
"name": "stderr",
"output_type": "stream",
"text": [
" 0%| | 0/13 [00:00<?, ?it/s]"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"100%|██████████| 13/13 [01:53<00:00, 8.75s/it]\n"
"100%|██████████| 14/14 [01:56<00:00, 8.31s/it]\n"
]
}
],
Expand Down
56 changes: 21 additions & 35 deletions examples/4_performance_analysis.ipynb

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pynumdiff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
from .polynomial_fit import splinediff, polydiff, savgoldiff
from .total_variation_regularization import tvrdiff, velocity, acceleration, jerk, iterative_velocity, smooth_acceleration, jerk_sliding
from .kalman_smooth import kalman_filter, rts_smooth, rtsdiff, constant_velocity, constant_acceleration, constant_jerk
from .linear_model import spectraldiff, lineardiff, rbfdiff
from .basis_fit import spectraldiff, rbfdiff
from .linear_model import lineardiff
5 changes: 5 additions & 0 deletions pynumdiff/basis_fit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Methods based on fitting basis functions to data
"""
from ._basis_fit import spectraldiff, rbfdiff

__all__ = ['spectraldiff', 'rbfdiff'] # So automodule from the .rst finds them
126 changes: 126 additions & 0 deletions pynumdiff/basis_fit/_basis_fit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import numpy as np
from warnings import warn
from scipy import sparse

from pynumdiff.utils import utility


def spectraldiff(x, dt, params=None, options=None, high_freq_cutoff=None, even_extension=True, pad_to_zero_dxdt=True):
"""Take a derivative in the fourier domain, with high frequency attentuation.

:param np.array[float] x: data to differentiate
:param float dt: step size
:param list[float] or float params: (**deprecated**, prefer :code:`high_freq_cutoff`)
:param dict options: (**deprecated**, prefer :code:`even_extension`
and :code:`pad_to_zero_dxdt`) a dictionary consisting of {'even_extension': (bool), 'pad_to_zero_dxdt': (bool)}
:param float high_freq_cutoff: The high frequency cutoff as a multiple of the Nyquist frequency: Should be between 0
and 1. Frequencies below this threshold will be kept, and above will be zeroed.
:param bool even_extension: if True, extend the data with an even extension so signal starts and ends at the same value.
:param bool pad_to_zero_dxdt: if True, extend the data with extra regions that smoothly force the derivative to
zero before taking FFT.

:return: tuple[np.array, np.array] of\n
- **x_hat** -- estimated (smoothed) x
- **dxdt_hat** -- estimated derivative of x
"""
if params != None: # Warning to support old interface for a while. Remove these lines along with params in a future release.
warn("`params` and `options` parameters will be removed in a future version. Use `high_freq_cutoff`, " +
"`even_extension`, and `pad_to_zero_dxdt` instead.", DeprecationWarning)
high_freq_cutoff = params[0] if isinstance(params, list) else params
if options != None:
if 'even_extension' in options: even_extension = options['even_extension']
if 'pad_to_zero_dxdt' in options: pad_to_zero_dxdt = options['pad_to_zero_dxdt']
elif high_freq_cutoff == None:
raise ValueError("`high_freq_cutoff` must be given.")

L = len(x)

# make derivative go to zero at ends (optional)
if pad_to_zero_dxdt:
padding = 100
pre = x[0]*np.ones(padding) # extend the edges
post = x[-1]*np.ones(padding)
x = np.hstack((pre, x, post))
kernel = utility.mean_kernel(padding//2)
x_hat = utility.convolutional_smoother(x, kernel) # smooth the edges in
x_hat[padding:-padding] = x[padding:-padding] # replace middle with original signal
x = x_hat
else:
padding = 0

# Do even extension (optional)
if even_extension is True:
x = np.hstack((x, x[::-1]))

# If odd, make N even, and pad x
N = len(x)

# Define the frequency range.
k = np.concatenate((np.arange(N//2 + 1), np.arange(-N//2 + 1, 0)))
if N % 2 == 0: k[N//2] = 0 # odd derivatives get the Nyquist element zeroed out
omega = k*2*np.pi/(dt*N) # turn wavenumbers into frequencies in radians/s

# Frequency based smoothing: remove signals with a frequency higher than high_freq_cutoff
discrete_cutoff = int(high_freq_cutoff*N/2) # Nyquist is at N/2 location, and we're cutting off as a fraction of that
omega[discrete_cutoff:N-discrete_cutoff] = 0

# Derivative = 90 deg phase shift
dxdt_hat = np.real(np.fft.ifft(1.0j * omega * np.fft.fft(x)))
dxdt_hat = dxdt_hat[padding:L+padding]

# Integrate to get x_hat
x_hat = utility.integrate_dxdt_hat(dxdt_hat, dt)
x0 = utility.estimate_integration_constant(x[padding:L+padding], x_hat)
x_hat = x_hat + x0

return x_hat, dxdt_hat


def rbfdiff(x, _t, sigma=1, lmbd=0.01):
"""Find smoothed function and derivative estimates by fitting noisy data with radial-basis-functions. Naively,
fill a matrix with basis function samples and solve a linear inverse problem against the data, but truncate tiny
values to make columns sparse. Each basis function "hill" is topped with a "tower" of height :code:`lmbd` to reach
noisy data samples, and the final smoothed reconstruction is found by razing these and only keeping the hills.

:param np.array[float] x: data to differentiate
:param float or array[float] _t: This function supports variable step size. This parameter is either the constant
:math:`\\Delta t` if given as a single float, or data locations if given as an array of same length as :code:`x`.
:param float sigma: controls width of radial basis functions
:param float lmbd: controls smoothness

:return: tuple[np.array, np.array] of\n
- **x_hat** -- estimated (smoothed) x
- **dxdt_hat** -- estimated derivative of x
"""
if np.isscalar(_t):
t = np.arange(len(x))*_t
else: # support variable step size for this function
if len(x) != len(_t): raise ValueError("If `_t` is given as array-like, must have same length as `x`.")
t = _t

# The below does the approximate equivalent of this code, but sparsely in O(N sigma^2), since the rbf falls off rapidly
# t_i, t_j = np.meshgrid(t,t)
# r = t_j - t_i # radius
# rbf = np.exp(-(r**2) / (2 * sigma**2)) # radial basis function kernel, O(N^2) entries
# drbfdt = -(r / sigma**2) * rbf # derivative of kernel
# rbf_regularized = rbf + lmbd*np.eye(len(t))
# alpha = np.linalg.solve(rbf_regularized, x) # O(N^3)

cutoff = np.sqrt(-2 * sigma**2 * np.log(1e-4))
rows, cols, vals, dvals = [], [], [], []
for n in range(len(t)):
# Only consider points within a cutoff. Gaussian drops below eps at distance ~ sqrt(-2*sigma^2 log eps)
l = np.searchsorted(t, t[n] - cutoff) # O(log N) to find indices of points within cutoff
r = np.searchsorted(t, t[n] + cutoff) # finds index where new value should be inserted
for j in range(l, r): # width of this is dependent on sigma. [l, r) is correct inclusion/exclusion
radius = t[n] - t[j]
v = np.exp(-radius**2 / (2 * sigma**2))
dv = -radius / sigma**2 * v # take derivative of radial basis function, because d/dt coef*f(t) = coef*df/dt
rows.append(n); cols.append(j); vals.append(v); dvals.append(dv)

rbf = sparse.csr_matrix((vals, (rows, cols)), shape=(len(t), len(t))) # Build sparse kernels, O(N sigma) entries
drbfdt = sparse.csr_matrix((dvals, (rows, cols)), shape=(len(t), len(t)))
rbf_regularized = rbf + lmbd*sparse.eye(len(t), format="csr") # identity matrix gives a little extra height at the centers
alpha = sparse.linalg.spsolve(rbf_regularized, x) # solve sparse system targeting the noisy data, O(N sigma^2)

return rbf @ alpha, drbfdt @ alpha # find samples of reconstructions using the smooth bases
10 changes: 4 additions & 6 deletions pynumdiff/linear_model/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"""This module implements interpolation-based differentiation schemes.
"""
try:
import cvxpy
from ._linear_model import lineardiff
Expand All @@ -8,8 +6,8 @@
warn("Limited Linear Model Support Detected! CVXPY is not installed. " +
"Install CVXPY to use lineardiff derivatives. You can still use other methods.")

from ._linear_model import savgoldiff, polydiff, spectraldiff, rbfdiff
from ._linear_model import savgoldiff, polydiff, spectraldiff

__all__ = ['lineardiff', 'spectraldiff', 'rbfdiff'] # So these get treated as direct members of the
# module by sphinx polydiff and savgoldiff are still imported for now for backwards compatibility
# but are not documented as part of this module, since they've moved
__all__ = ['lineardiff'] # Things in this list get treated as direct members of the module by sphinx
# polydiff and savgoldiff are still imported for now for backwards compatibility but are not documented
# as part of this module, since they've moved
129 changes: 6 additions & 123 deletions pynumdiff/linear_model/_linear_model.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import math, scipy
import numpy as np
from scipy import sparse
from warnings import warn

from pynumdiff.finite_difference import second_order as finite_difference
from pynumdiff.polynomial_fit import savgoldiff as _savgoldiff # patch through
from pynumdiff.polynomial_fit import polydiff as _polydiff # patch through
from pynumdiff.basis_fit import spectraldiff as _spectraldiff # patch through
from pynumdiff.utils import utility

try: import cvxpy
Expand All @@ -22,6 +22,11 @@ def polydiff(*args, **kwargs):
+ "`linear_model` in a future release.", DeprecationWarning)
return _polydiff(*args, **kwargs)

def spectraldiff(*args, **kwargs):
warn("`spectraldiff` has moved to `basis_fit.spectraldiff` and will be removed from "
+ "`linear_model` in a future release.", DeprecationWarning)
return _spectraldiff(*args, **kwargs)


def _solve_for_A_and_C_given_X_and_Xdot(X, Xdot, num_integrations, dt, gammaC=1e-1, gammaA=1e-6,
solver=None, A_known=None, epsilon=1e-6):
Expand Down Expand Up @@ -157,125 +162,3 @@ def _lineardiff(x, dt, order, gamma, solver=None):
x_hat, dxdt_hat = finite_difference(x_hat, dt)

return x_hat, dxdt_hat


def spectraldiff(x, dt, params=None, options=None, high_freq_cutoff=None, even_extension=True, pad_to_zero_dxdt=True):
"""Take a derivative in the fourier domain, with high frequency attentuation.

:param np.array[float] x: data to differentiate
:param float dt: step size
:param list[float] or float params: (**deprecated**, prefer :code:`high_freq_cutoff`)
:param dict options: (**deprecated**, prefer :code:`even_extension`
and :code:`pad_to_zero_dxdt`) a dictionary consisting of {'even_extension': (bool), 'pad_to_zero_dxdt': (bool)}
:param float high_freq_cutoff: The high frequency cutoff as a multiple of the Nyquist frequency: Should be between 0
and 1. Frequencies below this threshold will be kept, and above will be zeroed.
:param bool even_extension: if True, extend the data with an even extension so signal starts and ends at the same value.
:param bool pad_to_zero_dxdt: if True, extend the data with extra regions that smoothly force the derivative to
zero before taking FFT.

:return: tuple[np.array, np.array] of\n
- **x_hat** -- estimated (smoothed) x
- **dxdt_hat** -- estimated derivative of x
"""
if params != None: # Warning to support old interface for a while. Remove these lines along with params in a future release.
warn("`params` and `options` parameters will be removed in a future version. Use `high_freq_cutoff`, " +
"`even_extension`, and `pad_to_zero_dxdt` instead.", DeprecationWarning)
high_freq_cutoff = params[0] if isinstance(params, list) else params
if options != None:
if 'even_extension' in options: even_extension = options['even_extension']
if 'pad_to_zero_dxdt' in options: pad_to_zero_dxdt = options['pad_to_zero_dxdt']
elif high_freq_cutoff == None:
raise ValueError("`high_freq_cutoff` must be given.")

L = len(x)

# make derivative go to zero at ends (optional)
if pad_to_zero_dxdt:
padding = 100
pre = x[0]*np.ones(padding) # extend the edges
post = x[-1]*np.ones(padding)
x = np.hstack((pre, x, post))
kernel = utility.mean_kernel(padding//2)
x_hat = utility.convolutional_smoother(x, kernel) # smooth the edges in
x_hat[padding:-padding] = x[padding:-padding] # replace middle with original signal
x = x_hat
else:
padding = 0

# Do even extension (optional)
if even_extension is True:
x = np.hstack((x, x[::-1]))

# If odd, make N even, and pad x
N = len(x)

# Define the frequency range.
k = np.concatenate((np.arange(N//2 + 1), np.arange(-N//2 + 1, 0)))
if N % 2 == 0: k[N//2] = 0 # odd derivatives get the Nyquist element zeroed out
omega = k*2*np.pi/(dt*N) # turn wavenumbers into frequencies in radians/s

# Frequency based smoothing: remove signals with a frequency higher than high_freq_cutoff
discrete_cutoff = int(high_freq_cutoff*N/2) # Nyquist is at N/2 location, and we're cutting off as a fraction of that
omega[discrete_cutoff:N-discrete_cutoff] = 0

# Derivative = 90 deg phase shift
dxdt_hat = np.real(np.fft.ifft(1.0j * omega * np.fft.fft(x)))
dxdt_hat = dxdt_hat[padding:L+padding]

# Integrate to get x_hat
x_hat = utility.integrate_dxdt_hat(dxdt_hat, dt)
x0 = utility.estimate_integration_constant(x[padding:L+padding], x_hat)
x_hat = x_hat + x0

return x_hat, dxdt_hat


def rbfdiff(x, _t, sigma=1, lmbd=0.01):
"""Find smoothed function and derivative estimates by fitting noisy data with radial-basis-functions. Naively,
fill a matrix with basis function samples, similar to the implicit inverse problem of spectral methods, but
truncate tiny values to make columns sparse. Each basis function "hill" is topped with a "tower" of height
:code:`lmbd` to reach noisy data samples, and the final smoothed reconstruction is found by razing these and only
keeping the hills.

:param np.array[float] x: data to differentiate
:param float or array[float] _t: This function supports variable step size. This parameter is either the constant
:math:`\\Delta t` if given as a single float, or data locations if given as an array of same length as :code:`x`.
:param float sigma: controls width of radial basis functions
:param float lmbd: controls smoothness

:return: tuple[np.array, np.array] of\n
- **x_hat** -- estimated (smoothed) x
- **dxdt_hat** -- estimated derivative of x
"""
if np.isscalar(_t):
t = np.arange(len(x))*_t
else: # support variable step size for this function
if len(x) != len(_t): raise ValueError("If `_t` is given as array-like, must have same length as `x`.")
t = _t

# The below does the approximate equivalent of this code, but sparsely in O(N sigma^2), since the rbf falls off rapidly
# t_i, t_j = np.meshgrid(t,t)
# r = t_j - t_i # radius
# rbf = np.exp(-(r**2) / (2 * sigma**2)) # radial basis function kernel, O(N^2) entries
# drbfdt = -(r / sigma**2) * rbf # derivative of kernel
# rbf_regularized = rbf + lmbd*np.eye(len(t))
# alpha = np.linalg.solve(rbf_regularized, x) # O(N^3)

cutoff = np.sqrt(-2 * sigma**2 * np.log(1e-4))
rows, cols, vals, dvals = [], [], [], []
for n in range(len(t)):
# Only consider points within a cutoff. Gaussian drops below eps at distance ~ sqrt(-2*sigma^2 log eps)
l = np.searchsorted(t, t[n] - cutoff) # O(log N) to find indices of points within cutoff
r = np.searchsorted(t, t[n] + cutoff) # finds index where new value should be inserted
for j in range(l, r): # width of this is dependent on sigma. [l, r) is correct inclusion/exclusion
radius = t[n] - t[j]
v = np.exp(-radius**2 / (2 * sigma**2))
dv = -radius / sigma**2 * v
rows.append(n); cols.append(j); vals.append(v); dvals.append(dv)

rbf = sparse.csr_matrix((vals, (rows, cols)), shape=(len(t), len(t))) # Build sparse kernels, O(N sigma) entries
drbfdt = sparse.csr_matrix((dvals, (rows, cols)), shape=(len(t), len(t)))
rbf_regularized = rbf + lmbd*sparse.eye(len(t), format="csr") # identity matrix gives a little extra height at the centers
alpha = sparse.linalg.spsolve(rbf_regularized, x) # solve sparse system targeting the noisy data, O(N sigma^2)

return rbf @ alpha, drbfdt @ alpha # find samples of reconstructions using the smooth bases
Loading