Skip to content

Commit 28c186c

Browse files
authored
Merge pull request #179 from florisvb/multidimensional
big progress toward #76
2 parents b293cec + 8b5b524 commit 28c186c

13 files changed

Lines changed: 260 additions & 59 deletions

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,16 @@ The following heuristic works well for choosing `tvgamma`, where `cutoff_frequen
8282
### Notebook examples
8383

8484
Much more extensive usage is demonstrated in Jupyter notebooks:
85-
* Differentiation with different methods: [1_basic_tutorial.ipynb](https://github.com/florisvb/PyNumDiff/blob/master/examples/1_basic_tutorial.ipynb)
86-
* Parameter Optimization: [2_optimizing_hyperparameters.ipynb](https://github.com/florisvb/PyNumDiff/blob/master/examples/2_optimizing_hyperparameters.ipynb)
87-
* Automatic method suggestion: [3_automatic_method_suggestion.ipynb](https://github.com/florisvb/PyNumDiff/blob/master/examples/3_automatic_method_suggestion.ipynb)
85+
* Differentiation with different methods: [1_basic_tutorial.ipynb](https://github.com/florisvb/PyNumDiff/blob/master/notebooks/1_basic_tutorial.ipynb)
86+
* Parameter Optimization: [2_optimizing_hyperparameters.ipynb](https://github.com/florisvb/PyNumDiff/blob/master/notebooks/2_optimizing_hyperparameters.ipynb)
87+
88+
See the README in the `notebooks/` folder for a full guide to all demos and experiments.
8889

8990
## Repo Structure
9091

9192
- `.github/workflows` contains `.yaml` that configures our GitHub Actions continuous integration (CI) runs.
9293
- `docs/` contains `make` files and `.rst` files to govern the way `sphinx` builds documentation, either locally by navigating to this folder and calling `make html` or in the cloud by `readthedocs.io`.
93-
- `examples/` contains Jupyter notebooks that demonstrate some usage of the library.
94+
- `notebooks/` contains Jupyter notebooks that demonstrate some usage of the library.
9495
- `pynumdiff/` contains the source code. For a full list of modules and further navigation help, see the readme in this subfolder.
9596
- `.coveragerc` governs `coverage` runs, listing files and functions/lines that should be excluded, e.g. plotting code.
9697
- `.editorconfig` ensures tabs are displayed as 4 characters wide.
File renamed without changes.
File renamed without changes.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@
183183
],
184184
"metadata": {
185185
"kernelspec": {
186-
"display_name": "Python 3",
186+
"display_name": "Python 3 (ipykernel)",
187187
"language": "python",
188188
"name": "python3"
189189
},

notebooks/6_multidimensionality_demo.ipynb

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

notebooks/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Code Usage and Experiments
2+
3+
| Notebook | What's in it |
4+
| --- | --- |
5+
| [Basic Tutorial](https://github.com/florisvb/PyNumDiff/blob/master/notebooks/1_basic_tutorial.ipynb) | Demo to show invocations of all the major methods in this library on 1D data. |
6+
| [Optimizing Hyperparameters](https://github.com/florisvb/PyNumDiff/blob/master/notebooks/2_optimizing_hyperparameters.ipynb) | All methods' answers are affected by choices of hyperparameters, which complicates differentiation if the true derivative is not known. Here we briefly cover metrics we'd like to optimize and show how to use our `optimize` function to find good hyperparameter choices. |
7+
| [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. |
8+
| [Performance Analysis](https://github.com/florisvb/PyNumDiff/blob/master/notebooks/4_performance_analyssi.ipynb) | Experiments to compare methods' accuracy and bias across simulations. |
9+
| [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. |

pynumdiff/finite_difference.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from warnings import warn
66

77

8-
def finitediff(x, dt, num_iterations=1, order=2):
8+
def finitediff(x, dt, num_iterations=1, order=2, axis=0):
99
"""Perform iterated finite difference of a given order. This serves as the common backing function for
1010
all other methods in this module.
1111
@@ -14,20 +14,22 @@ def finitediff(x, dt, num_iterations=1, order=2):
1414
:param int num_iterations: number of iterations. If >1, the derivative is integrated with trapezoidal
1515
rule, that result is finite-differenced again, and the cycle is repeated num_iterations-1 times
1616
:param int order: 1, 2, or 4, controls which finite differencing scheme to employ
17+
:param int axis: data dimension along which differentiation is performed
1718
1819
:return: - **x_hat** (np.array) -- original x if :code:`num_iterations=1`, else smoothed x that yielded dxdt_hat
1920
- **dxdt_hat** (np.array) -- estimated derivative of x
2021
"""
2122
if num_iterations < 1: raise ValueError("num_iterations must be >0")
2223
if order not in [1, 2, 4]: raise ValueError("order must be 1, 2, or 4")
2324

25+
x = np.moveaxis(x, axis, 0) # move the axis of interest to the front to simplify differencing indexing
2426
x_hat = np.asarray(x) # allows for array-like. Preserve reference to x, for finding the final constant of integration
2527
dxdt_hat = np.zeros(x.shape) # preallocate reusable memory
2628

2729
# For all but the last iteration, do the differentate->integrate smoothing loop, being careful with endpoints
2830
for i in range(num_iterations-1):
2931
if order == 1:
30-
dxdt_hat[:-1] = np.diff(x_hat)
32+
dxdt_hat[:-1] = np.diff(x_hat, axis=0)
3133
dxdt_hat[-1] = dxdt_hat[-2] # using stencil -1,0 vs stencil 0,1 you get an expression for the same value
3234
elif order == 2:
3335
dxdt_hat[1:-1] = (x_hat[2:] - x_hat[:-2])/2 # second-order center-difference formula
@@ -40,13 +42,13 @@ def finitediff(x, dt, num_iterations=1, order=2):
4042
dxdt_hat[0] = x_hat[1] - x_hat[0]
4143
dxdt_hat[-1] = x_hat[-1] - x_hat[-2] # use first-order endpoint formulas so as not to amplify noise. See #104
4244

43-
x_hat = utility.integrate_dxdt_hat(dxdt_hat, 1) # estimate new x_hat by integrating derivative
45+
x_hat = utility.integrate_dxdt_hat(dxdt_hat, 1, axis=0) # estimate new x_hat by integrating derivative
4446
# We can skip dividing by dt here and pass dt=1, because the integration multiplies dt back in.
4547
# No need to find integration constant until the very end, because we just differentiate again.
4648
# Note that I also tried integrating with Simpson's rule here, and it seems to do worse. See #104
4749

4850
if order == 1:
49-
dxdt_hat[:-1] = np.diff(x_hat)
51+
dxdt_hat[:-1] = np.diff(x_hat, axis=0)
5052
dxdt_hat[-1] = dxdt_hat[-2] # using stencil -1,0 vs stencil 0,1 you get an expression for the same value
5153
elif order == 2:
5254
dxdt_hat[1:-1] = x_hat[2:] - x_hat[:-2] # second-order center-difference formula
@@ -63,9 +65,9 @@ def finitediff(x, dt, num_iterations=1, order=2):
6365
dxdt_hat /= dt # don't forget to scale by dt, can't skip it this time
6466

6567
if num_iterations > 1: # We've lost a constant of integration in the above
66-
x_hat += utility.estimate_integration_constant(x, x_hat)
68+
x_hat += utility.estimate_integration_constant(x, x_hat, axis=0)
6769

68-
return x_hat, dxdt_hat
70+
return np.moveaxis(x_hat, 0, axis), np.moveaxis(dxdt_hat, 0, axis) # reorder axes back to original
6971

7072

7173
def first_order(x, dt, params=None, options={}, num_iterations=1):

pynumdiff/tests/test_diff_methods.py

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from pytest import mark
33
from warnings import warn
44

5-
from ..finite_difference import first_order, second_order, fourth_order
5+
from ..finite_difference import finitediff, first_order, second_order, fourth_order
66
from ..linear_model import lineardiff
77
from ..basis_fit import spectraldiff, rbfdiff
88
from ..polynomial_fit import polydiff, savgoldiff, splinediff
@@ -235,25 +235,16 @@ def spline_irreg_step(*args, **kwargs): return splinediff(*args, **kwargs)
235235
@mark.parametrize("diff_method_and_params", diff_methods_and_params) # things like splinediff, with their parameters
236236
@mark.parametrize("test_func_and_deriv", test_funcs_and_derivs) # analytic functions, with their true derivatives
237237
def test_diff_method(diff_method_and_params, test_func_and_deriv, request): # request gives access to context
238+
"""Ensure differentiation methods find accurate derivatives"""
238239
# unpack
239240
diff_method, params = diff_method_and_params[:2]
240241
if len(diff_method_and_params) == 3: options = diff_method_and_params[2] # optionally pass old-style `options` dict
241242
i, latex_name, f, df = test_func_and_deriv
242243

243-
# some methods rely on cvxpy, and we'd like to allow use of pynumdiff without convex optimization
244-
if diff_method in [lineardiff, velocity, acceleration, jerk, smooth_acceleration, robustdiff]:
245-
try: import cvxpy
246-
except: warn(f"Cannot import cvxpy, skipping {diff_method} test."); return
247-
248244
# sample the true function and true derivative, and make noisy samples
249-
if diff_method in [spline_irreg_step, rbfdiff, rtsdiff]: # list that can handle variable dt
250-
x = f(t_irreg)
251-
dxdt = df(t_irreg)
252-
_t = t_irreg
253-
else:
254-
x = f(t)
255-
dxdt = df(t)
256-
_t = dt
245+
x = f(t) if diff_method not in [spline_irreg_step, rbfdiff, rtsdiff] else f(t_irreg)
246+
dxdt = df(t) if diff_method not in [spline_irreg_step, rbfdiff, rtsdiff] else df(t_irreg)
247+
_t = dt if diff_method not in [spline_irreg_step, rbfdiff, rtsdiff] else t_irreg
257248
x_noisy = x + noise
258249

259250
# differentiate without and with noise, accounting for new and old styles of calling functions
@@ -305,3 +296,69 @@ def test_diff_method(diff_method_and_params, test_func_and_deriv, request): # re
305296
# methods that get super duper close can converge to different very small limits on different runs
306297
if 1e-18 < l2_error < 10**(log_l2_bound - 1) or 1e-18 < linf_error < 10**(log_linf_bound - 1):
307298
print(f"Improvement detected for method {diff_method.__name__}")
299+
300+
301+
T1, T2 = np.meshgrid(np.linspace(-1, 1, 101), np.linspace(-1, 1, 101)) # a 101 x 101 grid
302+
dt2 = 0.02 # distance between samples in the 2D T grids
303+
x = T1**2 * np.sin(3/2 * np.pi * T2) # 2D function
304+
305+
# When one day all or most methods support multidimensionality, and the legacy way of calling methods is
306+
# gone, diff_methods_and_params can be used for the multidimensionality test as well
307+
multidim_methods_and_params = [(finitediff, {})]
308+
309+
# Similar to the error_bounds table, index by method first. But then we test against only one 2D function,
310+
# and only in the absence of noise, since the other test covers that. Instead, because multidimensional
311+
# derivatives can be combined in interesting fashions, we find d^2 / dt_1 dt_2 and the Laplacian,
312+
# d^2/dt_1^2 + d^2/dt_2^2. Tuples are again (L2,Linf) distances.
313+
multidim_error_bounds = {
314+
finitediff: [(0, -1), (1, -1)]
315+
}
316+
317+
@mark.parametrize("multidim_method_and_params", multidim_methods_and_params)
318+
def test_multidimensionality(multidim_method_and_params, request):
319+
"""Ensure methods with an axis parameter can successfully differentiate in independent directions"""
320+
diff_method, params = multidim_method_and_params
321+
322+
# d^2 / dt_1 dt_2
323+
analytic_d2 = 3 * T1 * np.pi * np.cos(3/2 * np.pi * T2)
324+
dxdt1 = diff_method(x, dt2, **params, axis=0)[1]
325+
computed_d2 = diff_method(dxdt1, dt2, **params, axis=1)[1]
326+
l2_error_d2 = np.linalg.norm(analytic_d2 - computed_d2) # Frobenius norm (2 norm of vectorized array)
327+
linf_error_d2 = np.max(np.abs(analytic_d2 - computed_d2))
328+
329+
# Laplacian
330+
analytic_laplacian = 2 * np.sin(3/2 * np.pi * T2) - 9/4 * np.pi**2 * T1**2 * np.sin(3/2 * np.pi * T2)
331+
dxdt2 = diff_method(x, dt2, **params, axis=1)[1]
332+
computed_laplacian = diff_method(dxdt1, dt2, **params, axis=0)[1] + diff_method(dxdt2, dt2, **params, axis=1)[1]
333+
l2_error_lap = np.linalg.norm(analytic_laplacian - computed_laplacian)
334+
linf_error_lap = np.max(np.abs(analytic_laplacian - computed_laplacian))
335+
336+
if request.config.getoption("--bounds"):
337+
print([(int(np.ceil(np.log10(l2_error_d2))), int(np.ceil(np.log10(linf_error_d2)))), (int(np.ceil(np.log10(l2_error_lap))), int(np.ceil(np.log10(linf_error_lap))))])
338+
else:
339+
(log_l2_bound_d2, log_linf_bound_d2), (log_l2_bound_lap, log_linf_bound_lap) = multidim_error_bounds[diff_method]
340+
assert l2_error_d2 < 10**log_l2_bound_d2
341+
assert linf_error_d2 < 10**log_linf_bound_d2
342+
343+
if request.config.getoption("--plot"):
344+
from matplotlib import pyplot
345+
fig = pyplot.figure(figsize=(12, 5), constrained_layout=True)
346+
ax1 = fig.add_subplot(1, 3, 1, projection='3d')
347+
ax1.plot_surface(T1, T2, x, cmap='viridis', alpha=0.5)
348+
ax1.set_title(r'original function, $x$')
349+
ax1.set_xlabel(r'$t_1$')
350+
ax1.set_ylabel(r'$t_2$')
351+
ax2 = fig.add_subplot(1, 3, 2, projection='3d')
352+
ax2.plot_surface(T1, T2, analytic_d2, cmap='viridis', alpha=0.5)
353+
ax2.set_title(r'$\frac{\partial^2 x}{\partial t_1 \partial t_2}$')
354+
ax2.set_xlabel(r'$t_1$')
355+
ax2.set_ylabel(r'$t_2$')
356+
ax3 = fig.add_subplot(1, 3, 3, projection='3d')
357+
surf = ax3.plot_surface(T1, T2, analytic_laplacian, cmap='viridis', alpha=0.5, label='analytic')
358+
ax3.set_title(r'$\frac{\partial^2}{\partial t_1^2} + \frac{\partial^2}{\partial t_2^2}$')
359+
ax3.set_xlabel(r'$t_1$')
360+
ax3.set_ylabel(r'$t_2$')
361+
362+
ax2.plot_wireframe(T1, T2, computed_d2)
363+
ax3.plot_wireframe(T1, T2, computed_laplacian, label='computed')
364+
legend = ax3.legend(bbox_to_anchor=(0.7, 0.8)); legend.legend_handles[0].set_facecolor(pyplot.cm.viridis(0.6))

0 commit comments

Comments
 (0)