Skip to content

Commit ad6041a

Browse files
authored
Merge pull request #198 from florisvb/circular-variable-support
Clean up circular variable support in rtsdiff
2 parents e5bd8e4 + fa2864f commit ad6041a

7 files changed

Lines changed: 197 additions & 448 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Python methods for numerical differentiation of noisy data, including multi-obje
2424

2525
## Introduction
2626

27-
PyNumDiff is a Python package that implements various methods for computing numerical derivatives of noisy data, which can be a critical step in developing dynamic models or designing control. There are seven different families of methods implemented in this repository:
27+
PyNumDiff is a Python package that implements many methods for computing numerical derivatives and smooth estimates of noisy data, which can be a critical step in developing dynamic models or designing control. There are seven different families of methods implemented in this repository:
2828

2929
1. prefiltering followed by finite difference calculation
3030
2. iterated finite differencing
@@ -34,9 +34,11 @@ PyNumDiff is a Python package that implements various methods for computing nume
3434
6. generalized Kalman smoothing
3535
7. local approximation with linear model
3636

37-
For a full list, explore modules in the [Sphinx documentation](https://pynumdiff.readthedocs.io/master/), or read section 7 of our [Taxonomy Paper](https://arxiv.org/abs/2512.09090).
37+
All are ultimately smoothing with similar runtime and accuracy, but some have situational advantages over others: For example, `robustdiff` is specialized to handle outliers; `splinediff`, `polydiff`, `rtsdiff`, and `robustdiff` can handle missing data; `splinediff`, `polydiff`, `rbfdiff`, `rtsdiff`, and `robustdiff` can handle irregularly-spaced data; and `rtsdiff` can handle inputs on a wrapped domain, like angles. All methods can accept blocks of multidimensional data, differentiating all vectors along the dimension given by the `axis` parameter.
3838

39-
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).
39+
For a full list and comparison, see section 7 of our [Taxonomy Paper](https://arxiv.org/abs/2512.09090) and explore modules in the [Sphinx documentation](https://pynumdiff.readthedocs.io/master/).
40+
41+
All methods have hyperparameters, so we take a principled approach and propose a multi-objective optimization framework for choosing settings 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).
4042

4143
## Installing
4244

notebooks/7_circular_domain.ipynb

Lines changed: 140 additions & 0 deletions
Large diffs are not rendered by default.

notebooks/7_circular_variables.ipynb

Lines changed: 0 additions & 365 deletions
This file was deleted.

notebooks/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
| [Automatic Method Suggestion](https://github.com/florisvb/PyNumDiff/blob/master/notebooks/3_automatic_method_suggestion.ipynb) | A short demo of how to allow `pynumdiff` to choose a differentiation method for your data. |
88
| [Performance Analysis](https://github.com/florisvb/PyNumDiff/blob/master/notebooks/4_performance_analysis.ipynb) | Experiments to compare methods' accuracy and bias across simulations. |
99
| [Robustness to Outliers Demo](https://github.com/florisvb/PyNumDiff/blob/master/notebooks/5_robust_outliers_demo.ipynb) | This notebook shows a head-to-head of `RTSDiff`'s and `RobustDiff`'s minimum-RMSE performances on simulations with outliers, to illustrate the value of using a Huber loss in the Kalman MAP problem. |
10-
| [Multidimensionality Demo](https://github.com/florisvb/PyNumDiff/blob/master/notebooks/6_multidimensionality_demo.ipynb) | Demonstration of differentating multidimensional data along particular axes. |
10+
| [Multidimensionality Demo](https://github.com/florisvb/PyNumDiff/blob/master/notebooks/6_multidimensionality_demo.ipynb) | Demonstration of differentating multidimensional data along particular axes. |
11+
| [Circular Domain](https://github.com/florisvb/PyNumDiff/blob/master/notebooks/7_circular_domain.ipynb) | Demonstrates improved handling of data on a wrapped domain (e.g. angles) using `RTSDiff` with specialized innovation distance function (inside the Kalman filter). |

pynumdiff/kalman_smooth.py

Lines changed: 20 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@
55
try: import cvxpy
66
except ImportError: pass
77

8-
from pynumdiff.utils.utility import huber_const, wrap_angle, ensure_iterable
8+
from pynumdiff.utils.utility import huber_const
99

1010

11-
def kalman_filter(y, xhat0, P0, A, Q, C, R, B=None, u=None, save_P=True,
12-
circular_vars=None, circular_units='rad'):
11+
def kalman_filter(y, xhat0, P0, A, Q, C, R, B=None, u=None, save_P=True, innovation_fn=None):
1312
"""Run the forward pass of a Kalman filter. Expects discrete-time matrices; use :func:`scipy.linalg.expm`
1413
in the caller to convert from continuous time if needed.
1514
@@ -25,10 +24,9 @@ def kalman_filter(y, xhat0, P0, A, Q, C, R, B=None, u=None, save_P=True,
2524
:param np.array u: optional control inputs, stacked in the direction of axis 0
2625
:param bool save_P: whether to save history of error covariance and a priori state estimates, used with rts
2726
smoothing but nonstandard to compute for ordinary filtering
28-
:param bool or None circular_vars: bool indicating whether the measurement y is a circular (angular) variable
29-
that is wrapped. This will use a circular innovation calculation for the Kalman filter. The smoothed result
30-
will be returned in an unwrapped form.
31-
:param string circular_units: 'rad' or 'deg' to specify whether wrapping is in degrees or radians.
27+
:param callable innovation_fn: optional function taking measurements and predicted measurements and returning the innovation.
28+
When :code:`None`, traditional subtraction is used. This is primarily exposed to handle angles, which have a wrapped domain,
29+
so alternative displacement measure :code:`lambda y, pred: (y - pred + np.pi) % (2*np.pi) - np.pi` is more appropriate.
3230
3331
:return: - **xhat_pre** (np.array) -- a priori estimates of xhat, with axis=0 the batch dimension, so xhat[n] gets the nth step
3432
- **xhat_post** (np.array) -- a posteriori estimates of xhat
@@ -62,9 +60,7 @@ def kalman_filter(y, xhat0, P0, A, Q, C, R, B=None, u=None, save_P=True,
6260
P = P_.copy()
6361
if not np.isnan(y[n]): # handle missing data
6462
K = P_ @ C.T @ np.linalg.inv(C @ P_ @ C.T + R)
65-
innovation = y[n] - C @ xhat_
66-
if circular_vars is not None and circular_vars is not False:
67-
innovation[0] = wrap_angle(innovation[0], circular_units)
63+
innovation = y[n] - C @ xhat_ if innovation_fn is None else innovation_fn(y[n], C @ xhat_)
6864
xhat += K @ innovation
6965
P -= K @ C @ P_
7066
# the [n]th index of pre variables holds _{n|n-1} info; the [n]th index of post variables holds _{n|n} info
@@ -102,8 +98,7 @@ def rts_smooth(A, xhat_pre, xhat_post, P_pre, P_post, compute_P_smooth=True):
10298
return xhat_smooth if not compute_P_smooth else (xhat_smooth, P_smooth)
10399

104100

105-
def rtsdiff(x, dt_or_t, order, log_qr_ratio, forwardbackward, axis=0,
106-
circular_vars=None, circular_units='rad'):
101+
def rtsdiff(x, dt_or_t, order, log_qr_ratio, forwardbackward=False, axis=0, circular=False):
107102
"""Perform Rauch-Tung-Striebel smoothing with a naive constant derivative model. Makes use of :code:`kalman_filter`
108103
and :code:`rts_smooth`, which are made public. :code:`constant_X` methods in this module call this function.
109104
@@ -118,24 +113,16 @@ def rtsdiff(x, dt_or_t, order, log_qr_ratio, forwardbackward, axis=0,
118113
:param bool forwardbackward: indicates whether to run smoother forwards and backwards
119114
(usually achieves better estimate at end points)
120115
:param int axis: data dimension along which differentiation is performed
121-
:param list[bool] circular_vars: set list element to bool for any axes of x that are a circular (angular) variable
122-
that is wrapped. This will use a circular innovation calculation for the Kalman filter. The smoothed result
123-
will be returned in an unwrapped form.
124-
:param string circular_units: 'rad' or 'deg' to specify whether wrapping is in degrees or radians.
116+
:param bool circular: if :code:`True`, treat the measured quantity as a circular variable in radians, wrapping the
117+
innovation to :math:`[-\\pi, \\pi]`. The input :code:`x` must be in radians; convert degrees with :code:`np.deg2rad`.
125118
126-
:return: - **x_hat** (np.array) -- estimated (smoothed) x, same shape as input :code:`x`
119+
:return: - **x_hat** (np.array) -- estimated (smoothed) x, same shape as input :code:`x`.
120+
When :code:`circular=True`, wrapped to :math:`[-\\pi, \\pi]`.
127121
- **dxdt_hat** (np.array) -- estimated derivative of x, same shape as input :code:`x`
128122
"""
129123
N = x.shape[axis]
130124
if not np.isscalar(dt_or_t) and N != len(dt_or_t):
131125
raise ValueError("If `dt_or_t` is given as array-like, must have same length as x along `axis`.")
132-
133-
# turn circular_vars into something with the same shape as the number of differentiated axes in x
134-
if len(x.shape) > 1:
135-
n = int(np.prod(x.shape[:axis] + x.shape[axis+1:]))
136-
circular_vars = ensure_iterable(circular_vars, n)
137-
else:
138-
circular_vars = ensure_iterable(circular_vars, 1)
139126

140127
q = 10**int(log_qr_ratio/2) # even-ish split of the powers across 0
141128
r = q/(10**log_qr_ratio)
@@ -160,29 +147,31 @@ def rtsdiff(x, dt_or_t, order, log_qr_ratio, forwardbackward, axis=0,
160147
Q_d[n] = eM[:order+1, order+1:] @ A_d[n].T
161148
if forwardbackward: A_d_bwd = np.linalg.inv(A_d[::-1]) # properly broadcasts, taking inv of each stacked 2D array
162149

150+
innovation_fn = (lambda y, pred: (y - pred + np.pi) % (2*np.pi) - np.pi) if circular else None # optionally wrap innovation to [-pi, pi], see #178
151+
163152
x_hat = np.empty_like(x); dxdt_hat = np.empty_like(x)
164153
if forwardbackward: w = np.linspace(0, 1, N) # weights used to combine forward and backward results
165154

166-
for i, vec_idx in enumerate(np.ndindex(x.shape[:axis] + x.shape[axis+1:])): # works properly for 1D case too
155+
for vec_idx in np.ndindex(x.shape[:axis] + x.shape[axis+1:]): # works properly for 1D case too
167156
s = vec_idx[:axis] + (slice(None),) + vec_idx[axis:] # for indexing the vector we wish to differentiate
168-
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
157+
xhat0 = np.zeros(order+1)
158+
if not np.isnan(x[s][0]): xhat0[0] = x[s][0] # The first estimate is the first seen state. See #110
169159

170-
xhat_pre, xhat_post, P_pre, P_post = kalman_filter(x[s], xhat0, P0, A_d, Q_d, C, R,
171-
circular_vars=circular_vars[i], circular_units=circular_units)
160+
xhat_pre, xhat_post, P_pre, P_post = kalman_filter(x[s], xhat0, P0, A_d, Q_d, C, R, innovation_fn=innovation_fn)
172161
xhat_smooth = rts_smooth(A_d, xhat_pre, xhat_post, P_pre, P_post, compute_P_smooth=False)
173162
x_hat[s] = xhat_smooth[:,0] # first dimension is time, so slice first and second states at all times
174163
dxdt_hat[s] = xhat_smooth[:,1]
175164

176165
if forwardbackward:
177-
xhat0[0] = x[s][-1] if not np.isnan(x[s][-1]) else 0
166+
if not np.isnan(x[s][-1]): xhat0[0] = x[s][-1]
178167
xhat_pre, xhat_post, P_pre, P_post = kalman_filter(x[s][::-1], xhat0, P0, A_d_bwd,
179-
Q_d if Q_d.ndim == 2 else Q_d[::-1], C, R,
180-
circular_vars=circular_vars[i], circular_units=circular_units) # Use same Q matrices as before, because noise should still grow in reverse time
168+
Q_d if Q_d.ndim == 2 else Q_d[::-1], C, R, innovation_fn=innovation_fn) # Use same Q matrices as before, because noise should still grow in reverse time
181169
xhat_smooth = rts_smooth(A_d_bwd, xhat_pre, xhat_post, P_pre, P_post, compute_P_smooth=False)
182170

183171
x_hat[s] = x_hat[s] * w + xhat_smooth[:, 0][::-1] * (1-w)
184172
dxdt_hat[s] = dxdt_hat[s] * w + xhat_smooth[:, 1][::-1] * (1-w)
185173

174+
if circular: x_hat = (x_hat + np.pi) % (2*np.pi) - np.pi # wrap output to match the input domain, see #178
186175
return x_hat, dxdt_hat
187176

188177

pynumdiff/tests/test_diff_methods.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,36 @@ def test_multidimensionality(multidim_method_and_params, request):
405405
legend = ax3.legend(bbox_to_anchor=(0.7, 0.8)); legend.legend_handles[0].set_facecolor(pyplot.cm.viridis(0.6))
406406
fig.suptitle(f'{diff_method.__name__}', fontsize=16)
407407

408+
def test_circular_rtsdiff(request):
409+
"""Ensure rtsdiff with circular=True correctly differentiates a wrapping angle signal in radians"""
410+
dthdt = 5 # constant angular velocity in rad/s
411+
th = dthdt * t # linearly increasing angle, crosses 2*pi boundaries
412+
th_noisy = np.angle(np.exp(1j * (th + noise))) # add noise and wrap to [-pi, pi]
413+
414+
th_hat_naive, dthdt_hat_naive = rtsdiff(th_noisy, dt, order=1, log_qr_ratio=1, circular=False)
415+
th_hat, dthdt_hat = rtsdiff(th_noisy, dt, order=1, log_qr_ratio=1, circular=True)
416+
417+
naive_rmse = np.sqrt(np.mean((dthdt_hat_naive - dthdt)**2))
418+
wrapped_rmse = np.sqrt(np.mean((dthdt_hat - dthdt)**2))
419+
assert wrapped_rmse < naive_rmse
420+
421+
if request.config.getoption("--plot"):
422+
from matplotlib import pyplot
423+
fig, (ax1, ax2) = pyplot.subplots(2, 1, figsize=(10, 6), sharex=True)
424+
ax1.plot(t, th_noisy, 'k+', label=r'$\theta$ noisy (wrapped)')
425+
ax1.plot(t, th_hat_naive, 'C1--', label=r'$\hat{\theta}$ with circular=False')
426+
ax1.plot(t, th_hat, 'C0', label=r'$\hat{\theta}$ with circular=True')
427+
ax1.set_ylabel(r'$\theta$ (rad)')
428+
ax1.legend()
429+
ax2.axhline(dthdt, color='C2', xmin=0.045, xmax=0.955, label=r'true $\dot{\theta}$')
430+
ax2.plot(t, dthdt_hat_naive, 'C1--', label=r'$\hat{\dot{\theta}}$ circular=False')
431+
ax2.plot(t, dthdt_hat, 'C0', label=r'$\hat{\dot{\theta}}$ circular=True')
432+
ax2.set_ylabel(r'$\dot{\theta}$ (rad/time)')
433+
ax2.set_xlabel('t')
434+
ax2.legend()
435+
fig.suptitle('rtsdiff with circular domain', fontsize=16)
436+
437+
408438
# List of methods that can handle missing values
409439
nan_methods_and_params = [
410440
(splinediff, {'degree': 5, 's': 2}),

pynumdiff/utils/utility.py

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -202,51 +202,3 @@ def peakdet(x, delta, t=None):
202202
return np.array(maxtab), np.array(mintab)
203203

204204

205-
def wrap_angle(angle, units='rad', range='symmetric'):
206-
"""Wrap an angle to a specified range.
207-
208-
:param np.array angle: angular values
209-
:param string units: either 'rad' or 'deg'
210-
:param string range: either 'symmetric' for [-pi, pi] / [-180, 180],
211-
or 'positive' for [0, 2pi] / [0, 360]
212-
213-
:return: - **angle** -- the angular values wrapped as requested
214-
"""
215-
if units == 'rad':
216-
period = 2 * np.pi
217-
if range == 'symmetric':
218-
return (angle + np.pi) % period - np.pi
219-
elif range == 'positive':
220-
return angle % period
221-
else:
222-
raise ValueError(f"Invalid range '{range}'. Expected 'symmetric' or 'positive'.")
223-
224-
elif units == 'deg':
225-
period = 360.
226-
if range == 'symmetric':
227-
return (angle + 180.) % period - 180.
228-
elif range == 'positive':
229-
return angle % period
230-
else:
231-
raise ValueError(f"Invalid range '{range}'. Expected 'symmetric' or 'positive'.")
232-
233-
else:
234-
raise ValueError(f"Invalid units '{units}'. Expected 'rad' or 'deg'.")
235-
236-
237-
def ensure_iterable(v, length):
238-
"""Ensure v is a list of the specified length.
239-
240-
If v is not iterable (e.g. a scalar), it is broadcast
241-
into a list by repeating it `length` times. If it is already iterable,
242-
it is returned as-is.
243-
244-
:param v: a scalar or iterable
245-
:param int length: desired length of the output list when broadcasting a scalar
246-
:return: v repeated `length` times if scalar, otherwise unchanged
247-
248-
:return: - **v** -- list or iterable
249-
"""
250-
if not hasattr(v, '__iter__'):
251-
return [v] * length
252-
return v

0 commit comments

Comments
 (0)