1import collections
2import collections.abc as cabc
3import itertools
4import math
5import types
6import typing as typ
7
8import numpy as np
9
10import pyxu.info.deps as pxd
11import pyxu.info.ptype as pxt
12import pyxu.operator.blocks as pxb
13import pyxu.operator.linop.base as pxlb
14import pyxu.operator.linop.pad as pxlp
15import pyxu.operator.linop.reduce as pxlr
16import pyxu.operator.linop.stencil.stencil as pxls
17import pyxu.operator.misc as pxm
18import pyxu.runtime as pxrt
19import pyxu.util as pxu
20
21try:
22 import scipy.ndimage._filters as scif
23except ImportError:
24 import scipy.ndimage.filters as scif
25
26__all__ = [
27 "PartialDerivative",
28 "Gradient",
29 "Jacobian",
30 "Divergence",
31 "Hessian",
32 "Laplacian",
33 "DirectionalDerivative",
34 "DirectionalGradient",
35 "DirectionalLaplacian",
36 "DirectionalHessian",
37]
38
39ModeSpec = pxlp.Pad.ModeSpec
40KernelSpec = pxls.Stencil.KernelSpec
41PDMetaFD = collections.namedtuple("FiniteDifferenceMeta", "sampling scheme accuracy")
42PDMetaGD = collections.namedtuple("GaussianDerivativeMeta", "sampling sigma truncate")
43
44
45def _sanitize_init_kwargs(
46 order: typ.Union[pxt.Integer, cabc.Sequence[pxt.Integer, ...]],
47 dim_shape: pxt.NDArrayShape,
48 sampling: typ.Union[pxt.Real, cabc.Sequence[pxt.Real, ...]],
49 diff_method: str,
50 diff_kwargs: dict,
51 axes: pxt.NDArrayAxis = None,
52) -> tuple[
53 cabc.Sequence[pxt.Integer, ...],
54 cabc.Sequence[pxt.Real, ...],
55 typ.Union[cabc.Sequence[pxt.Real, ...], cabc.Sequence[str, ...]],
56 cabc.Sequence[pxt.Integer, ...],
57 pxt.NDArrayAxis,
58]:
59 r"""
60 Ensures that inputs have the appropriate shape and values.
61 """
62
63 dim_shape = pxu.as_canonical_shape(dim_shape)
64
65 def _ensure_tuple(param, param_name: str) -> typ.Union[tuple[pxt.Integer, ...], tuple[str, ...]]:
66 r"""
67 Enforces the input parameters to be tuples of the same size as `dim_shape`.
68 """
69 if not isinstance(param, cabc.Sequence) or isinstance(param, str):
70 param = (param,)
71 assert (len(param) == 1) | (len(param) <= len(dim_shape)), (
72 f"The length of {param_name} cannot be larger than the"
73 f"number of dimensions ({len(dim_shape)}) defined by `dim_shape`"
74 )
75 return param
76
77 order = _ensure_tuple(order, param_name="order")
78 sampling = _ensure_tuple(sampling, param_name="sampling")
79 if len(sampling) == 1:
80 sampling = sampling * len(dim_shape)
81 assert all([_ >= 0 for _ in order]), "Order must be positive"
82 assert all([_ > 0 for _ in sampling]), "Sampling must be strictly positive"
83
84 if diff_method == "fd":
85 param1_name = "scheme"
86 param2_name = "accuracy"
87 param1 = diff_kwargs.get("scheme", "forward")
88 param2 = diff_kwargs.get("accuracy", 1)
89 elif diff_method == "gd":
90 param1_name = "sigma"
91 param2_name = "truncate"
92 param1 = diff_kwargs.get("sigma", 1.0)
93 param2 = diff_kwargs.get("truncate", 3.0)
94 else:
95 raise NotImplementedError
96
97 _param1 = _ensure_tuple(param1, param_name=param1_name)
98 _param2 = _ensure_tuple(param2, param_name=param2_name)
99
100 if param1_name == "sigma":
101 assert all([p >= 0 for p in _param1]), "Sigma must be strictly positive"
102 if param2_name == "accuracy":
103 assert all([p >= 0 for p in _param2]), "Accuracy must be positive"
104 elif param2_name == "truncate":
105 assert all([p > 0 for p in _param2]), "Truncate must be strictly positive"
106
107 if len(order) != len(dim_shape):
108 assert axes is not None, (
109 "If `order` is not a tuple with size of dim_shape, then `axes` must be" " specified. Got `axes=None`"
110 )
111 axes = _ensure_tuple(axes, param_name="axes")
112 assert len(axes) == len(order), "`axes` must have the same number of elements as `order`"
113 else:
114 if axes is not None:
115 axes = _ensure_tuple(axes, param_name="axes")
116 assert len(axes) == len(order), "`axes` must have the same number of elements as `order`"
117 else:
118 axes = tuple([i for i in range(len(dim_shape))])
119
120 if not (len(_param1) == len(order)):
121 assert len(_param1) == 1, (
122 f"Parameter `{param1_name}` inconsistent with the number of elements in " "parameter `order`."
123 )
124 _param1 = _param1 * len(dim_shape)
125
126 if not (len(_param2) == len(order)):
127 assert len(_param2) == 1, (
128 f"Parameter `{param2_name}` inconsistent with the number of elements in " "parameter `order`."
129 )
130 _param2 = _param2 * len(dim_shape)
131
132 return (
133 order,
134 sampling,
135 _param1,
136 _param2,
137 axes,
138 )
139
140
141def _create_kernel(
142 dim_shape: pxt.NDArrayShape,
143 axes: pxt.NDArrayAxis,
144 _fill_coefs: typ.Callable,
145) -> tuple[cabc.Sequence[pxt.NDArray], pxt.NDArray]:
146 r"""
147 Creates kernel for stencil.
148 """
149 stencil_ids = [np.array([0])] * len(dim_shape)
150 stencil_coefs = [np.array([1.0])] * len(dim_shape)
151 center = np.zeros(len(dim_shape), dtype=int)
152
153 # Create finite difference coefficients for each dimension
154 for i, ax in enumerate(axes):
155 stencil_ids[ax], stencil_coefs[ax], center[ax] = _fill_coefs(i)
156
157 return stencil_coefs, center
158
159
160def _FiniteDifference(
161 order: typ.Union[pxt.Integer, cabc.Sequence[pxt.Integer, ...]],
162 dim_shape: pxt.NDArrayShape,
163 scheme: typ.Union[str, cabc.Sequence[str, ...]] = "forward",
164 axes: pxt.NDArrayAxis = None,
165 accuracy: typ.Union[pxt.Integer, cabc.Sequence[pxt.Integer, ...]] = 1,
166 sampling: typ.Union[pxt.Real, cabc.Sequence[pxt.Real, ...]] = 1,
167) -> tuple[cabc.Sequence[pxt.NDArray], pxt.NDArray]:
168 r"""
169 Finite difference base operator along a single dimension.
170
171 This class is used by :py:class:`~pyxu.operator.PartialDerivative`,
172 :py:class:`~pyxu.operator.Gradient` and :py:class:`~pyxu.operator.Hessian`.
173 See :py:class:`~pyxu.operator.PartialDerivative.finite_difference` for documentation.
174
175 See Also
176 --------
177 :py:class:`~pyxu.operator._GaussianDerivative`,
178 :py:class:`~pyxu.operator.PartialDerivative`,
179 :py:class:`~pyxu.operator.Gradient`,
180 :py:class:`~pyxu.operator.Hessian`.
181
182 Parameters
183 ----------
184 order: Integer, list[Integer]
185 Derivative order. If a single integer value is provided, then `axes` should be provided to
186 indicate which dimension should be differentiated.
187 If a tuple is provided, it should contain as many elements as `dim_shape`.
188 dim_shape: pxt.NDArrayShape
189 Shape of the input array.
190 scheme: str, list[str]
191 Type of finite differences: ["forward", "backward", "central"].
192 Defaults to "forward".
193 axes: NDArrayAxis
194 Axes to which apply the derivative.
195 It maps the argument `order` to the specified dimensions of the input array.
196 Defaults to None, assuming that the `order` argument has as many elements as dimensions of
197 the input.
198 accuracy: Integer, list[Integer]
199 Determines the number of points used to approximate the derivative with finite differences
200 (see `Notes`).
201 Defaults to 1.
202 If an int is provided, the same `accuracy` is assumed for all dimensions.
203 If a tuple is provided, it should have as many elements as `dim_shape`.
204 sampling: pxt.Real, list[Real]
205 Sampling step (i.e. distance between two consecutive elements of an array).
206 Defaults to 1.
207 """
208 diff_kwargs = {"scheme": scheme, "accuracy": accuracy}
209 order, sampling, scheme, accuracy, axes = _sanitize_init_kwargs(
210 order=order,
211 diff_method="fd",
212 diff_kwargs=diff_kwargs,
213 dim_shape=dim_shape,
214 axes=axes,
215 sampling=sampling,
216 )
217
218 def _compute_ids(order: pxt.Integer, scheme: str, accuracy: pxt.Integer) -> list:
219 """
220 Computes the Finite difference indices according to the order, type and accuracy.
221 """
222 if scheme == "central":
223 n_coefs = 2 * ((order + 1) // 2) - 1 + accuracy
224 ids = np.arange(-(n_coefs // 2), n_coefs // 2 + 1, dtype=int)
225 else:
226 n_coefs = order + accuracy
227 if scheme == "forward":
228 ids = np.arange(0, n_coefs, dtype=int)
229 elif scheme == "backward":
230 ids = np.arange(-n_coefs + 1, 1, dtype=int)
231 else:
232 raise ValueError(
233 f"Incorrect value for variable 'type'. 'type' should be ['forward', 'backward', "
234 f"'central'], but got {scheme}."
235 )
236 return ids.tolist()
237
238 def _compute_coefficients(stencil_ids: cabc.Sequence, order: pxt.Integer, sampling: pxt.Real) -> pxt.NDArray:
239 """
240 Computes the finite difference coefficients based on the order and indices.
241 """
242 # vander doesn't allow precision specification
243 stencil_mat = np.vander(
244 np.array(stencil_ids),
245 increasing=True,
246 ).T
247 vec = np.zeros(len(stencil_ids))
248 vec[order] = math.factorial(order)
249 coefs = np.linalg.solve(stencil_mat, vec)
250 coefs /= sampling**order
251 return coefs
252
253 # FILL COEFFICIENTS
254 def _fill_coefs(i: pxt.Integer) -> tuple[list[pxt.NDArray], pxt.Integer]:
255 r"""
256 Defines kernel elements.
257 """
258 stencil_ids = _compute_ids(order=order[i], scheme=scheme[i], accuracy=accuracy[i])
259 stencil_coefs = _compute_coefficients(stencil_ids=stencil_ids, order=order[i], sampling=sampling[i])
260 center = stencil_ids.index(0)
261 return stencil_ids, stencil_coefs, center
262
263 kernel, center = _create_kernel(dim_shape, axes, _fill_coefs)
264 return kernel, center
265
266
267def _GaussianDerivative(
268 order: typ.Union[pxt.Integer, cabc.Sequence[pxt.Integer, ...]],
269 dim_shape: pxt.NDArrayShape,
270 sigma: typ.Union[pxt.Real, cabc.Sequence[pxt.Real, ...]],
271 axes: pxt.NDArrayAxis = None,
272 truncate: typ.Union[pxt.Real, cabc.Sequence[pxt.Real, ...]] = 3.0,
273 sampling: typ.Union[pxt.Real, cabc.Sequence[pxt.Real, ...]] = 1,
274):
275 r"""
276 Gaussian derivative base operator along a single dimension.
277
278 This class is used by :py:class:`~pyxu.operator.PartialDerivative`,
279 :py:class:`~pyxu.operator.Gradient` and :py:class:`~pyxu.operator.Hessian`.
280 See :py:class:`~pyxu.operator.PartialDerivative.gaussian_derivative` for documentation.
281
282 See Also
283 --------
284 :py:class:`~pyxu.operator._BaseDifferential`,
285 :py:class:`~pyxu.operator._FiniteDifference`,
286 :py:class:`~pyxu.operator.PartialDerivative`,
287 :py:class:`~pyxu.operator.Gradient`,
288 :py:class:`~pyxu.operator.Hessian`.
289
290 Parameters
291 ----------
292 order: Integer, list[Integer]
293 Derivative order.
294 If a single integer value is provided, then `axes` should be provided to indicate which
295 dimension should be used for differentiation.
296 If a tuple is provided, it should contain as many elements as number of dimensions in
297 `axes`.
298 dim_shape: pxt.NDArrayShape
299 Shape of the input array.
300 sigma: Real, list[Real]
301 Standard deviation of the Gaussian kernel.
302 axes: pxt.NDArrayAxis
303 Axes to which apply the derivative.
304 It maps the argument `order` to the specified dimensions of the input array.
305 Defaults to None, assuming that the `order` argument has as many elements as dimensions of
306 the input.
307 truncate: Real, list[Real]
308 Truncate the filter at this many standard deviations (at each side from the origin).
309 Defaults to 3.0.
310 sampling: Real, list[Real]
311 Sampling step (i.e., the distance between two consecutive elements of an array).
312 Defaults to 1.
313 """
314 diff_kwargs = {"sigma": sigma, "truncate": truncate}
315 order, sampling, sigma, truncate, axes = _sanitize_init_kwargs(
316 order=order,
317 diff_method="gd",
318 diff_kwargs=diff_kwargs,
319 dim_shape=dim_shape,
320 axes=axes,
321 sampling=sampling,
322 )
323
324 def _fill_coefs(i: pxt.Integer) -> tuple[cabc.Sequence[pxt.Integer], pxt.NDArray, pxt.Integer]:
325 r"""
326 Defines kernel elements.
327 """
328 # make the radius of the filter equal to `truncate` standard deviations
329 sigma_pix = sigma[i] / sampling[i] # Sigma rescaled to pixel units
330 radius = int(truncate[i] * float(sigma_pix) + 0.5)
331 stencil_coefs = _gaussian_kernel1d(sigma=sigma_pix, order=order[i], sampling=sampling[i], radius=radius)
332 stencil_ids = [i for i in range(-radius, radius + 1)]
333 return stencil_ids, stencil_coefs, radius
334
335 def _gaussian_kernel1d(
336 sigma: pxt.Real,
337 order: pxt.Integer,
338 sampling: pxt.Real,
339 radius: pxt.Integer,
340 ) -> pxt.NDArray:
341 """
342 Computes a 1-D Gaussian convolution kernel.
343 Wraps scipy.ndimage.filters._gaussian_kernel1d
344 It flips the output because the original kernel is meant for convolution instead of correlation.
345 """
346 coefs = np.flip(scif._gaussian_kernel1d(sigma, order, radius))
347 coefs /= sampling**order
348 return coefs
349
350 kernel, center = _create_kernel(dim_shape, axes, _fill_coefs)
351 return kernel, center
352
353
354def _PartialDerivative(
355 kernel: KernelSpec,
356 center: KernelSpec,
357 dim_shape: pxt.NDArrayShape,
358 mode: ModeSpec = "constant",
359 gpu: bool = False,
360 dtype: typ.Optional[pxt.DType] = None,
361 meta: typ.Optional[typ.NamedTuple] = None,
362) -> pxt.OpT:
363 r"""
364 Helper base class for partial derivative operator based on Numba stencils (see
365 https://numba.pydata.org/numba-doc/latest/user/stencil.html).
366
367 See Also
368 --------
369 :py:class:`~pyxu.operator.PartialDerivative`,
370 :py:class:`~pyxu.operator.Stencil`,
371 :py:class:`~pyxu.operator._FiniteDifference`,
372 :py:class:`~pyxu.operator._GaussianDerivative`.
373
374 Parameters
375 ----------
376 kernel: KernelSpec
377 Stencil coefficients.
378 Two forms are accepted:
379
380 * NDArray of rank-:math:`D`: denotes a non-seperable stencil.
381 * tuple[NDArray_1, ..., NDArray_D]: a sequence of 1D stencils such that is filtered by
382 the stencil `kernel[d]` along the :math:`d`-th dimension.
383 center: IndexSpec
384 (i_1, ..., i_D) index of the stencil's center.
385
386 `center` defines how a kernel is overlaid on inputs to produce outputs.
387
388 .. math::
389
390 y[i_{1},\ldots,i_{D}]
391 =
392 \sum_{q_{1},\ldots,q_{D}=0}^{k_{1},\ldots,k_{D}}
393 x[i_{1} - c_{1} + q_{1},\ldots,i_{D} - c_{D} + q_{D}]
394 \,\cdot\,
395 k[q_{1},\ldots,q_{D}]
396 dim_shape: pxt.NDArrayShape
397 Shape of the input array.
398 mode: str, list[str]
399 Boundary conditions.
400 Multiple forms are accepted:
401
402 * str: unique mode shared amongst dimensions.
403 Must be one of:
404
405 * 'constant' (default): zero-padding
406 * 'wrap'
407 * 'reflect'
408 * 'symmetric'
409 * 'edge'
410 * tuple[str, ...]: the `d`-th dimension uses ``mode[d]`` as boundary condition.
411
412 (See :py:func:`numpy.pad` for details.)
413 gpu: bool
414 Input NDArray type (`True` for GPU, `False` for CPU). Defaults to `False`.
415 dtype: DType
416 Working precision of the linear operator.
417 meta: typ.NamedTuple
418 Partial derivative metadata describing:
419
420 * Diff_method
421 * Sampling
422 * Scheme (for finite differences)
423 * Accuracy (for finite differences)
424 * Sigma (for Gaussian derivative)
425 * Truncate (for Gaussian derivative)
426 """
427 if dtype is None:
428 dtype = pxrt.Width.DOUBLE.value
429
430 if gpu:
431 assert pxd.CUPY_ENABLED
432 import cupy as xp
433 else:
434 import numpy as xp
435
436 if isinstance(kernel, cabc.MutableSequence):
437 for i in range(len(kernel)):
438 kernel[i] = xp.array(kernel[i], dtype=dtype)
439 else:
440 kernel = xp.array(kernel, dtype=dtype)
441
442 op = pxls.Stencil(dim_shape=dim_shape, kernel=kernel, center=center, mode=mode)
443 setattr(op, "meta", meta)
444
445 return op
446
447
[docs]
448class PartialDerivative:
449 r"""
450 Partial derivative operator based on `Numba stencils
451 <https://numba.pydata.org/numba-doc/latest/user/stencil.html>`_.
452
453 Notes
454 -----
455 * This operator approximates the partial derivative of a :math:`D`-dimensional signal :math:`\mathbf{f} \in
456 \mathbb{R}^{N_0 \times \cdots \times N_{D-1}}`
457
458 .. math::
459
460 \frac{\partial^{n} \mathbf{f}}{\partial x_0^{n_0} \cdots \partial x_{D-1}^{n_{D-1}}} \in
461 \mathbb{R}^{N_0 \times \cdots \times N_{D-1}}
462
463 where :math:`\frac{\partial^{n_i}}{\partial x_i^{n_i}}` is the :math:`n_i`-th order partial derivative along
464 dimension :math:`i` and :math:`n = \prod_{i=0}^{D-1} n_{i}` is the total derivative order.
465
466 Partial derivatives can be implemented with `finite differences
467 <https://en.wikipedia.org/wiki/Finite_difference>`_ via the
468 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` constructor, or with the `Gaussian
469 derivative <https://www.crisluengo.net/archives/22/>`_ via the
470 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative` constructor.
471
472 * When using the :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` constructor, the adjoint
473 of the resulting linear operator will vary depending on the type of finite differences:
474
475 * For ``forward`` type, the adjoint corresponds to:
476
477 :math:`(\frac{\partial^{\text{fwd}}}{\partial x})^{\ast} = -\frac{\partial^{\text{bwd}}}{\partial x}`
478
479 * For ``backward`` type, the adjoint corresponds to:
480
481 :math:`(\frac{\partial^{\text{bwd}}}{\partial x})^{\ast} = -\frac{\partial^{\text{fwd}}}{\partial x}`
482
483 * For ``central`` type, and for the :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative`
484 constructor, the adjoint corresponds to:
485
486 :math:`(\frac{\partial}{\partial x})^{\ast} = -\frac{\partial}{\partial x}`
487
488
489 .. warning::
490
491 When dealing with high-order partial derivatives, the stencils required to compute them can become large,
492 resulting in computationally expensive evaluations.
493 In such scenarios, it can be more efficient to construct the partial derivative through a composition of
494 lower-order partial derivatives.
495
496 See Also
497 --------
498 :py:class:`~pyxu.operator.Gradient`,
499 :py:class:`~pyxu.operator.Laplacian`,
500 :py:class:`~pyxu.operator.Hessian`.
501 """
502
[docs]
503 @staticmethod
504 def finite_difference(
505 dim_shape: pxt.NDArrayShape,
506 order: cabc.Sequence[pxt.Integer, ...],
507 scheme: typ.Union[str, cabc.Sequence[str, ...]] = "forward",
508 accuracy: typ.Union[pxt.Integer, cabc.Sequence[pxt.Integer, ...]] = 1,
509 mode: ModeSpec = "constant",
510 gpu: bool = False,
511 dtype: typ.Optional[pxt.DType] = None,
512 sampling: typ.Union[pxt.Real, cabc.Sequence[pxt.Real, ...]] = 1,
513 ) -> pxt.OpT:
514 r"""
515 Compute partial derivatives for multi-dimensional signals using finite differences.
516
517 Parameters
518 ----------
519 dim_shape: NDArrayShape
520 (N_1,...,N_D) input dimensions.
521 order: list[Integer]
522 Derivative order for each dimension.
523 The total order of the partial derivative is the sum of the elements of the tuple.
524 Use zeros to indicate dimensions in which the derivative should not be computed.
525 scheme: str, list[str]
526 Type of finite differences: ['forward, 'backward, 'central'].
527 Defaults to 'forward'.
528 If a string is provided, the same `scheme` is assumed for all dimensions.
529 If a tuple is provided, it should have as many elements as `order`.
530 accuracy: Integer, list[Integer]
531 Determines the number of points used to approximate the derivative with finite differences (see `Notes`).
532 Defaults to 1.
533 If an int is provided, the same `accuracy` is assumed for all dimensions.
534 If a tuple is provided, it should have as many elements as `dim_shape`.
535 mode: str, list[str]
536 Boundary conditions.
537 Multiple forms are accepted:
538
539 * str: unique mode shared amongst dimensions.
540 Must be one of:
541
542 * 'constant' (default): zero-padding
543 * 'wrap'
544 * 'reflect'
545 * 'symmetric'
546 * 'edge'
547 * tuple[str, ...]: the `d`-th dimension uses ``mode[d]`` as boundary condition.
548
549 (See :py:func:`numpy.pad` for details.)
550 gpu: bool
551 Input NDArray type (`True` for GPU, `False` for CPU).
552 Defaults to `False`.
553 dtype: DType
554 Working precision of the linear operator.
555 sampling: Real, list[Real]
556 Sampling step (i.e. distance between two consecutive elements of an array).
557 Defaults to 1.
558
559 Returns
560 -------
561 op: OpT
562 Partial derivative
563
564 Notes
565 -----
566 We explain here finite differences for one-dimensional signals; this operator performs finite differences for
567 multi-dimensional signals along dimensions specified by `order`.
568
569 This operator approximates derivatives with `finite differences
570 <https://en.wikipedia.org/wiki/Finite_difference>`_.
571 It is inspired by the `Finite Difference Coefficients Calculator
572 <https://web.media.mit.edu/~crtaylor/calculator.html>`_ to construct finite-difference approximations for the
573 desired *(i)* derivative order, *(ii)* approximation accuracy, and *(iii)* finite difference type.
574 Three basic types of finite differences are supported, which lead to the following first-order (``order = 1``)
575 operators with ``accuracy = 1`` and sampling step ``sampling = h`` for one-dimensional signals:
576
577 - **Forward difference**:
578 Approximates the continuous operator :math:`D_{F}f(x) = \frac{f(x+h) - f(x)}{h}` with the discrete operator
579
580 .. math::
581
582 \mathbf{D}_{F} f [n] = \frac{f[n+1] - f[n]}{h},
583
584 whose kernel is :math:`d = \frac{1}{h}[-1, 1]` and center is (0, ).
585
586 - **Backward difference**:
587 Approximates the continuous operator :math:`D_{B}f(x) = \frac{f(x) - f(x-h)}{h}` with the discrete operator
588
589 .. math::
590
591 \mathbf{D}_{B} f [n] = \frac{f[n] - f[n-1]}{h},
592
593 whose kernel is :math:`d = \frac{1}{h}[-1, 1]` and center is (1, ).
594
595 - **Central difference**:
596 Approximates the continuous operator :math:`D_{C}f(x) = \frac{f(x+h) - f(x-h)}{2h}` with the discrete operator
597
598 .. math::
599
600 \mathbf{D}_{C} f [n] = \frac{f[n+1] - f[n-1]}{2h},
601
602 whose kernel is :math:`d = \frac{1}{h}[-\frac12, 0, \frac12]` and center is (1, ).
603
604 .. warning::
605
606 For forward and backward differences, higher-order operators correspond to the composition of first-order
607 operators.
608 This is not the case for central differences: the second-order continuous operator is given by
609 :math:`D^2_{C}f(x) = \frac{f(x+h) - 2 f(x) + f(x-h)}{h}`, hence :math:`D^2_{C} \neq D_{C} \circ D_{C}`.
610 The corresponding discrete operator is given by :math:`\mathbf{D}^2_{C} f [n] = \frac{f[n+1] - 2 f[n] +
611 f[n-1]}{h}`, whose kernel is :math:`d = \frac{1}{h}[1, -2, 1]` and center is (1, ).
612 We refer to `this paper
613 <https://www.ams.org/journals/mcom/1988-51-184/S0025-5718-1988-0935077-0/S0025-5718-1988-0935077-0.pdf>`_ for
614 more details.
615
616 For a given derivative order :math:`N\in\mathbb{Z}^{+}` and accuracy :math:`a\in\mathbb{Z}^{+}`, the size
617 :math:`N_s` of the stencil kernel :math:`d` used for finite differences is given by:
618
619 - For central differences: :math:`N_s = 2 \lfloor\frac{N + 1}{2}\rfloor - 1 + a`
620
621 - For forward and backward differences: :math:`N_s = N + a`
622
623 For :math:`N_s` given support indices :math:`\{s_1, \ldots , s_{N_s} \} \subset \mathbb{Z}` and a derivative
624 order :math:`N<N_s`, the stencil kernel :math:`d = [d_1, \ldots, d_{N_s}]` of the finite-difference
625 approximation of the derivative is obtained by solving the following system of linear equations (see the `Finite
626 Difference Coefficients Calculator <https://web.media.mit.edu/~crtaylor/calculator.html>`_ documentation):
627
628 .. admonition:: Remark
629
630 The number of coefficients of the finite-difference kernel is chosen to guarantee the requested accuracy, but
631 might be larger than requested accuracy.
632 For example, if choosing `scheme='central'` with `accuracy=1`, it will create a kernel corresponding to
633 `accuracy=2`, as it is the minimum accuracy possible for such scheme (see the `finite difference coefficient
634 table <https://en.wikipedia.org/wiki/Finite_difference_coefficient>`_).
635
636 .. math::
637
638 \left(\begin{array}{ccc}
639 s_{1}^{0} & \cdots & s_{N_s}^{0} \\
640 \vdots & \ddots & \vdots \\
641 s_{1}^{N_s-1} & \cdots & s_{N_s}^{N_s-1}
642 \end{array}\right)\left(\begin{array}{c}
643 d_{1} \\
644 \vdots \\
645 d_{N_s}
646 \end{array}\right)= \frac{1}{h^{N}}\left(\begin{array}{c}
647 \delta_{0, N} \\
648 \vdots \\
649 \delta_{i, N} \\
650 \vdots \\
651 \delta_{N_s-1, N}
652 \end{array}\right),
653
654 where :math:`\delta_{i, j}` is the Kronecker delta.
655
656 This class inherits its methods from :py:class:`~pyxu.operator.Stencil`.
657
658 Example
659 -------
660
661 .. plot::
662
663 import numpy as np
664 import matplotlib.pyplot as plt
665 from pyxu.operator import PartialDerivative
666 from pyxu.util.misc import peaks
667
668 x = np.linspace(-2.5, 2.5, 25)
669 xx, yy = np.meshgrid(x, x)
670 image = peaks(xx, yy)
671 dim_shape = image.shape # Shape of our image
672 # Specify derivative order at each direction
673 df_dx = (1, 0) # Compute derivative of order 1 in first dimension
674 d2f_dy2 = (0, 2) # Compute derivative of order 2 in second dimension
675 d3f_dxdy2 = (1, 2) # Compute derivative of order 1 in first dimension and der. of order 2 in second dimension
676 # Instantiate derivative operators
677 sigma = 2.0
678 diff1 = PartialDerivative.gaussian_derivative(order=df_dx, dim_shape=dim_shape, sigma=sigma / np.sqrt(2))
679 diff2 = PartialDerivative.gaussian_derivative(order=d2f_dy2, dim_shape=dim_shape, sigma=sigma / np.sqrt(2))
680 diff = PartialDerivative.gaussian_derivative(order=d3f_dxdy2, dim_shape=dim_shape, sigma=sigma)
681 # Compute derivatives
682 out1 = (diff1 * diff2)(image)
683 out2 = diff(image)
684 # Plot derivatives
685 fig, axs = plt.subplots(1, 3, figsize=(15, 4))
686 im = axs[0].imshow(image)
687 axs[0].axis("off")
688 axs[0].set_title("f(x,y)")
689 plt.colorbar(im, ax=axs[0])
690 axs[1].imshow(out1)
691 axs[1].axis("off")
692 axs[1].set_title(r"$\frac{\partial^{3} f(x,y)}{\partial x\partial y^{2}}$")
693 plt.colorbar(im, ax=axs[1])
694
695 axs[2].imshow(out2)
696 axs[2].axis("off")
697 axs[2].set_title(r"$\frac{\partial^{3} f(x,y)}{\partial x\partial y^{2}}$")
698 plt.colorbar(im, ax=axs[2])
699
700 # Check approximation error
701 plt.figure()
702 plt.imshow(abs(out1 - out2)), plt.colorbar()
703
704 """
705 assert isinstance(order, cabc.Sequence), "`order` should be a tuple / list"
706 dim_shape = pxu.as_canonical_shape(dim_shape)
707 assert len(order) == len(dim_shape)
708 diff_kwargs = {"scheme": scheme, "accuracy": accuracy}
709 order, sampling, scheme, accuracy, _ = _sanitize_init_kwargs(
710 order=order,
711 diff_method="fd",
712 diff_kwargs=diff_kwargs,
713 dim_shape=dim_shape,
714 sampling=sampling,
715 )
716
717 # Compute a kernel for each axis
718 kernel = [np.array(1)] * len(dim_shape)
719 center = np.zeros(len(dim_shape), dtype=int)
720 for ax in range(len(dim_shape)):
721 if order[ax] > 0:
722 k, c = _FiniteDifference(
723 order=order[ax],
724 dim_shape=dim_shape,
725 scheme=scheme[ax],
726 axes=ax,
727 accuracy=accuracy[ax],
728 sampling=sampling[ax],
729 )
730 kernel[ax] = k[ax]
731 center[ax] = c[ax]
732
733 meta = PDMetaFD(sampling=sampling, scheme=scheme, accuracy=accuracy)
734
735 return _PartialDerivative(
736 kernel=kernel,
737 center=center,
738 dim_shape=dim_shape,
739 mode=mode,
740 gpu=gpu,
741 dtype=dtype,
742 meta=meta,
743 )
744
[docs]
745 @staticmethod
746 def gaussian_derivative(
747 dim_shape: pxt.NDArrayShape,
748 order: cabc.Sequence[pxt.Integer, ...],
749 sigma: typ.Union[pxt.Real, cabc.Sequence[pxt.Real, ...]] = 1.0,
750 truncate: typ.Union[pxt.Real, cabc.Sequence[pxt.Real, ...]] = 3.0,
751 mode: ModeSpec = "constant",
752 gpu: bool = False,
753 dtype: typ.Optional[pxt.DType] = None,
754 sampling: typ.Union[pxt.Real, cabc.Sequence[pxt.Real, ...]] = 1,
755 ) -> pxt.OpT:
756 r"""
757 Compute partial derivatives for multi-dimensional signals using gaussian derivatives.
758
759 Parameters
760 ----------
761 dim_shape: NDArrayShape
762 (N_1,...,N_D) input dimensions.
763 order: list[Integer]
764 Derivative order for each dimension.
765 The total order of the partial derivative is the sum of the elements of the tuple.
766 Use zeros to indicate dimensions in which the derivative should not be computed.
767 sigma: Real, list[Real]
768 Standard deviation for the Gaussian kernel.
769 Defaults to 1.0.
770 If a float is provided, the same `sigma` is assumed for all dimensions.
771 If a tuple is provided, it should have as many elements as `order`.
772 truncate: Real, list[Real]
773 Truncate the filter at this many standard deviations (at each side from the origin).
774 Defaults to 3.0.
775 If a float is provided, the same `truncate` is assumed for all dimensions.
776 If a tuple is provided, it should have as many elements as `order`.
777 mode: str, list[str]
778 Boundary conditions.
779 Multiple forms are accepted:
780
781 * str: unique mode shared amongst dimensions.
782 Must be one of:
783
784 * 'constant' (default): zero-padding
785 * 'wrap'
786 * 'reflect'
787 * 'symmetric'
788 * 'edge'
789 * tuple[str, ...]: the `d`-th dimension uses ``mode[d]`` as boundary condition.
790
791 (See :py:func:`numpy.pad` for details.)
792 gpu: bool
793 Input NDArray type (`True` for GPU, `False` for CPU).
794 Defaults to `False`.
795 dtype: DType
796 Working precision of the linear operator.
797 sampling: Real, list[Real]
798 Sampling step (i.e., the distance between two consecutive elements of an array).
799 Defaults to 1.
800
801 Returns
802 -------
803 op: OpT
804 Partial derivative.
805
806 Notes
807 -----
808 We explain here Gaussian derivatives for one-dimensional signals; this operator performs partial Gaussian
809 derivatives for multi-dimensional signals along dimensions specified by ``order``.
810
811 A Gaussian derivative is an approximation of a derivative that consists in convolving the input function with a
812 Gaussian function :math:`g` before applying a derivative.
813 In the continuous domain, the :math:`N`-th order Gaussian derivative :math:`D^N_G` amounts to a convolution with
814 the :math:`N`-th order derivative of :math:`g`:
815
816 .. math::
817
818 D^N_G f (x)
819 =
820 \frac{\mathrm{d}^N (f * g) }{\mathrm{d} x^N} (x)
821 =
822 f(x) * \frac{\mathrm{d}^N g}{\mathrm{d} x^N} (x).
823
824 For discrete signals :math:`f[n]`, this operator is approximated by
825
826 .. math::
827
828 \mathbf{D}^N_G f [n]
829 =
830 f[n] *\frac{\mathrm{d}^N g}{\mathrm{d} x^N} \left(\frac{n}{h}\right),
831
832 where :math:`h` is the spacing between samples and the operator :math:`*` is now a discrete convolution.
833
834 .. warning::
835
836 The operator :math:`\mathbf{D}_{G} \circ \mathbf{D}_{G}` is not directly related to
837 :math:`\mathbf{D}_{G}^{2}`: Gaussian smoothing is performed twice in the case of the former, whereas it is
838 performed only once in the case of the latter.
839
840 Note that in contrast with finite differences (see
841 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference`), Gaussian derivatives compute exact
842 derivatives in the continuous domain, since Gaussians can be differentiated analytically.
843 This derivative is then sampled in order to perform a discrete convolution.
844
845 This class inherits its methods from :py:class:`~pyxu.operator.Stencil`.
846
847 Example
848 -------
849
850 .. plot::
851
852 import numpy as np
853 import matplotlib.pyplot as plt
854 from pyxu.operator import PartialDerivative
855 from pyxu.util.misc import peaks
856
857 x = np.linspace(-2.5, 2.5, 25)
858 xx, yy = np.meshgrid(x, x)
859 image = peaks(xx, yy)
860 dim_shape = image.shape # Shape of our image
861 # Specify derivative order at each direction
862 df_dx = (1, 0) # Compute derivative of order 1 in first dimension
863 d2f_dy2 = (0, 2) # Compute derivative of order 2 in second dimension
864 d3f_dxdy2 = (1, 2) # Compute derivative of order 1 in first dimension and der. of order 2 in second dimension
865 # Instantiate derivative operators
866 diff1 = PartialDerivative.gaussian_derivative(order=df_dx, dim_shape=dim_shape, sigma=2.0)
867 diff2 = PartialDerivative.gaussian_derivative(order=d2f_dy2, dim_shape=dim_shape, sigma=2.0)
868 diff = PartialDerivative.gaussian_derivative(order=d3f_dxdy2, dim_shape=dim_shape, sigma=2.0)
869 # Compute derivatives
870 out1 = (diff1 * diff2)(image)
871 out2 = diff(image)
872 plt.figure()
873 plt.imshow(image),
874 plt.axis('off')
875 plt.colorbar()
876 plt.title('f(x,y)')
877 plt.figure()
878 plt.imshow(out1.T)
879 plt.axis('off')
880 plt.title(r'$\frac{\partial^{3} f(x,y)}{\partial x\partial y^{2}}$')
881 plt.figure()
882 plt.imshow(out2.T)
883 plt.axis('off')
884 plt.title(r'$\frac{\partial^{3} f(x,y)}{\partial x\partial y^{2}}$')
885
886 """
887 assert isinstance(order, cabc.Sequence), "`order` should be a tuple / list"
888 dim_shape = pxu.as_canonical_shape(dim_shape)
889 assert len(order) == len(dim_shape)
890
891 diff_kwargs = {"sigma": sigma, "truncate": truncate}
892 order, sampling, sigma, truncate, _ = _sanitize_init_kwargs(
893 order=order,
894 diff_method="gd",
895 diff_kwargs=diff_kwargs,
896 dim_shape=dim_shape,
897 sampling=sampling,
898 )
899
900 # Compute a kernel for each axes
901 kernel = [np.array(1)] * len(dim_shape)
902 center = np.zeros(len(dim_shape), dtype=int)
903 for ax in range(len(dim_shape)):
904 k, c = _GaussianDerivative(
905 order=order[ax],
906 dim_shape=dim_shape,
907 sigma=sigma[ax],
908 axes=ax,
909 truncate=truncate[ax],
910 sampling=sampling[ax],
911 )
912 kernel[ax] = k[ax]
913 center[ax] = c[ax]
914
915 meta = PDMetaGD(sampling=sampling, sigma=sigma, truncate=truncate)
916
917 return _PartialDerivative(
918 kernel=kernel,
919 center=center,
920 dim_shape=dim_shape,
921 mode=mode,
922 gpu=gpu,
923 dtype=dtype,
924 meta=meta,
925 )
926
927
928class _StackDiffHelper:
929 r"""
930 Helper class for Gradient and Hessian.
931
932 Defines a method for computing and stacking partial derivatives.
933
934 See Also
935 --------
936 :py:class:`~pyxu.operator.Gradient`,
937 :py:class:`~pyxu.operator.Laplacian`,
938 :py:class:`~pyxu.operator.Hessian`.
939 """
940
941 @staticmethod
942 def _stack_diff_ops(
943 dim_shape: pxt.NDArrayShape,
944 directions: pxt.NDArrayAxis,
945 diff_method: str,
946 order: typ.Union[pxt.Integer, cabc.Sequence[pxt.Integer, ...]],
947 param1: typ.Union[str, cabc.Sequence[str, ...], pxt.Real, cabc.Sequence[pxt.Real, ...]],
948 param2: typ.Union[pxt.Integer, cabc.Sequence[pxt.Integer, ...], pxt.Real, cabc.Sequence[pxt.Real, ...]],
949 mode: ModeSpec = "constant",
950 gpu: bool = False,
951 dtype: typ.Optional[pxt.DType] = None,
952 sampling: typ.Union[pxt.Real, cabc.Sequence[pxt.Real, ...]] = 1,
953 ) -> pxt.OpT:
954 if isinstance(mode, str):
955 mode = (mode,)
956 if isinstance(mode, cabc.Sequence):
957 if len(mode) != len(dim_shape):
958 assert len(mode) == 1
959 mode = mode * len(dim_shape)
960
961 else:
962 raise ValueError("mode has to be a string or a tuple")
963 dif_op = []
964 for i in range(0, len(directions)):
965 _order = np.zeros_like(dim_shape)
966 _order[directions[i]] = order[i]
967 _param1 = param1 if not isinstance(param1[0], (list, tuple)) else param1[i]
968 _param2 = param2 if not isinstance(param2[0], (list, tuple)) else param2[i]
969 if diff_method == "fd":
970 dif_op.append(
971 PartialDerivative.finite_difference(
972 dim_shape=dim_shape,
973 order=tuple(_order),
974 scheme=_param1,
975 accuracy=_param2,
976 mode=mode,
977 gpu=gpu,
978 dtype=dtype,
979 sampling=sampling,
980 )
981 )
982
983 elif diff_method == "gd":
984 dif_op.append(
985 PartialDerivative.gaussian_derivative(
986 dim_shape=dim_shape,
987 order=tuple(_order),
988 sigma=param1,
989 truncate=param2,
990 mode=mode,
991 gpu=gpu,
992 dtype=dtype,
993 sampling=sampling,
994 )
995 )
996
997 def visualize(_) -> str:
998 r"""
999 Show the :math:`D`-dimensional stacked partial derivative kernels.
1000
1001 The kernel's center is identified by surrounding parentheses.
1002
1003 Example
1004 -------
1005 .. code-block:: python3
1006
1007 from pyxu.operator import Hessian
1008
1009 H = Hessian(
1010 dim_shape=(5, 6),
1011 diff_method="fd",
1012 )
1013 print(H.visualize()) # Direction 0
1014 # [[1.0]
1015 # [(-2.0)]
1016 # [1.0]]
1017 #
1018 # Direction 1
1019 # [[(1.0) -1.0]
1020 # [-1.0 1.0]]
1021 #
1022 # Direction 2
1023 # [[1.0 (-2.0) 1.0]]
1024 """
1025 kernels = []
1026 for direction, stencil in enumerate(_._ops):
1027 kernels.append(f"\nDirection {direction} \n" + stencil.visualize())
1028 return "\n".join(kernels)
1029
1030 if diff_method == "fd":
1031 meta = PDMetaFD(
1032 sampling=[op.meta.sampling for op in dif_op],
1033 scheme=[op.meta.scheme for op in dif_op],
1034 accuracy=[op.meta.accuracy for op in dif_op],
1035 )
1036 else: # diff_method == "gd"
1037 meta = PDMetaGD(
1038 sampling=[op.meta.sampling for op in dif_op],
1039 sigma=[op.meta.sigma for op in dif_op],
1040 truncate=[op.meta.truncate for op in dif_op],
1041 )
1042 op = pxb.stack(dif_op)
1043 setattr(op, "visualize", types.MethodType(visualize, op))
1044 setattr(op, "meta", meta)
1045 return op
1046
1047 @staticmethod
1048 def _check_directions_and_order(
1049 dim_shape: pxt.NDArrayShape,
1050 directions: typ.Union[
1051 str,
1052 pxt.NDArrayAxis,
1053 cabc.Sequence[pxt.NDArrayAxis, ...],
1054 ],
1055 ) -> cabc.Sequence[cabc.Sequence[pxt.NDArrayAxis, ...], pxt.NDArrayAxis]:
1056 # Convert directions to canonical form
1057 def _check_directions(_directions):
1058 assert all(0 <= _ <= (len(dim_shape) - 1) for _ in _directions), (
1059 "Direction values must be between 0 and " "the number of dimensions in `dim_shape`."
1060 )
1061
1062 if not isinstance(directions, cabc.Sequence):
1063 # This corresponds to [mode 0] in `Notes`
1064 directions = [directions, directions]
1065 _check_directions(directions)
1066 directions = (directions,)
1067 else:
1068 if isinstance(directions, str):
1069 # This corresponds to [mode 3] in Hessian `Notes`
1070 assert directions == "all", (
1071 "Value for `directions` not implemented. The accepted directions types are"
1072 "int, tuple or a str with the value `all`."
1073 )
1074 directions = tuple(
1075 list(_) for _ in itertools.combinations_with_replacement(np.arange(len(dim_shape)).astype(int), 2)
1076 )
1077 elif not isinstance(directions[0], cabc.Sequence):
1078 # This corresponds to [mode 1] in Hessian `Notes`
1079 assert len(directions) == 2, (
1080 "If `directions` is a tuple, it should contain two elements, corresponding "
1081 "to the i-th an j-th elements (dx_i and dx_j)"
1082 )
1083 directions = list(directions)
1084 _check_directions(directions)
1085 directions = (directions,)
1086 else:
1087 # This corresponds to [mode 2] in Hessian `Notes`
1088 for direction in directions:
1089 _check_directions(direction)
1090
1091 # Convert to canonical form for PartialDerivative (direction + order)
1092 _directions = [
1093 list(direction) if (len(np.unique(direction)) == len(direction)) else np.unique(direction).tolist()
1094 for direction in directions
1095 ]
1096
1097 _order = [3 - len(np.unique(direction)) for direction in directions]
1098
1099 return _directions, _order
1100
1101
[docs]
1102def Gradient(
1103 dim_shape: pxt.NDArrayShape,
1104 directions: typ.Optional[pxt.NDArrayAxis] = None,
1105 diff_method: str = "fd",
1106 mode: ModeSpec = "constant",
1107 gpu: bool = False,
1108 dtype: typ.Optional[pxt.DType] = None,
1109 **diff_kwargs,
1110) -> pxt.OpT:
1111 r"""
1112 Gradient operator.
1113
1114 Notes
1115 -----
1116
1117 This operator stacks the first-order partial derivatives of a :math:`D`-dimensional signal :math:`\mathbf{f} \in
1118 \mathbb{R}^{N_{0} \times \cdots \times N_{D-1}}` along each dimension:
1119
1120 .. math::
1121
1122 \boldsymbol{\nabla} \mathbf{f} = \begin{bmatrix}
1123 \frac{\partial \mathbf{f}}{\partial x_0} \\
1124 \vdots \\
1125 \frac{\partial \mathbf{f}}{\partial x_{D-1}}
1126 \end{bmatrix} \in \mathbb{R}^{D \times N_{0} \times \cdots \times N_{D-1}}
1127
1128 The partial derivatives can be approximated by `finite differences
1129 <https://en.wikipedia.org/wiki/Finite_difference>`_ via the
1130 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` constructor or by the `Gaussian derivative
1131 <https://www.crisluengo.net/archives/22/>`_ via
1132 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative` constructor.
1133 The parametrization of the partial derivatives can be done via the keyword arguments `\*\*diff_kwargs`, which will
1134 default to the same values as the :py:class:`~pyxu.operator.PartialDerivative` constructor.
1135
1136 Parameters
1137 ----------
1138 dim_shape: NDArrayShape
1139 (N_1,...,N_D) input dimensions.
1140 directions: Integer, list[Integer], None
1141 Gradient directions.
1142 Defaults to `None`, which computes the gradient for all directions.
1143 diff_method: 'gd', 'fd'
1144 Method used to approximate the derivative.
1145 Must be one of:
1146
1147 * 'fd' (default): finite differences
1148 * 'gd': Gaussian derivative
1149 mode: str, list[str]
1150 Boundary conditions.
1151 Multiple forms are accepted:
1152
1153 * str: unique mode shared amongst dimensions.
1154 Must be one of:
1155
1156 * 'constant' (default): zero-padding
1157 * 'wrap'
1158 * 'reflect'
1159 * 'symmetric'
1160 * 'edge'
1161 * tuple[str, ...]: the `d`-th dimension uses ``mode[d]`` as boundary condition.
1162
1163 (See :py:func:`numpy.pad` for details.)
1164 gpu: bool
1165 Input NDArray type (`True` for GPU, `False` for CPU).
1166 Defaults to `False`.
1167 dtype: DType
1168 Working precision of the linear operator.
1169 diff_kwargs: dict
1170 Keyword arguments to parametrize partial derivatives (see
1171 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` and
1172 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative`)
1173
1174 Returns
1175 -------
1176 op: OpT
1177 Gradient
1178
1179 Example
1180 -------
1181
1182 .. plot::
1183
1184 import numpy as np
1185 import matplotlib.pyplot as plt
1186 from pyxu.operator import Gradient
1187 from pyxu.util.misc import peaks
1188
1189 # Define input image
1190 n = 100
1191 x = np.linspace(-3, 3, n)
1192 xx, yy = np.meshgrid(x, x)
1193 image = peaks(xx, yy)
1194 dim_shape = image.shape # (1000, 1000)
1195 # Instantiate gradient operator
1196 grad = Gradient(dim_shape=dim_shape)
1197
1198 # Compute gradients
1199 df_dx, df_dy = grad(image) # shape = (2, 1000, 1000)
1200
1201 # Plot image
1202 fig, axs = plt.subplots(1, 3, figsize=(15, 4))
1203 im = axs[0].imshow(image)
1204 axs[0].set_title("Image")
1205 axs[0].axis("off")
1206 plt.colorbar(im, ax=axs[0])
1207
1208 # Plot gradient
1209 im = axs[1].imshow(df_dx)
1210 axs[1].set_title(r"$\partial f/ \partial x$")
1211 axs[1].axis("off")
1212 plt.colorbar(im, ax=axs[1])
1213 im = axs[2].imshow(df_dy)
1214 axs[2].set_title(r"$\partial f/ \partial y$")
1215 axs[2].axis("off")
1216 plt.colorbar(im, ax=axs[2])
1217
1218 See Also
1219 --------
1220 :py:func:`~pyxu.operator.PartialDerivative`,
1221 :py:func:`~pyxu.operator.Jacobian`.
1222 """
1223 dim_shape = pxu.as_canonical_shape(dim_shape)
1224 directions = tuple([i for i in range(len(dim_shape))]) if directions is None else directions
1225 axes = tuple([i for i in range(len(dim_shape)) if i in directions])
1226 order, sampling, param1, param2, _ = _sanitize_init_kwargs(
1227 order=(1,) * len(directions),
1228 axes=axes,
1229 dim_shape=dim_shape,
1230 sampling=diff_kwargs.get("sampling", 1.0),
1231 diff_method=diff_method,
1232 diff_kwargs=diff_kwargs,
1233 )
1234 op = _StackDiffHelper._stack_diff_ops(
1235 dim_shape=dim_shape,
1236 directions=directions,
1237 diff_method=diff_method,
1238 order=order,
1239 param1=param1,
1240 param2=param2,
1241 mode=mode,
1242 gpu=gpu,
1243 dtype=dtype,
1244 sampling=sampling,
1245 )
1246 op._name = "Gradient"
1247 return op
1248
1249
[docs]
1250def Jacobian(
1251 dim_shape: pxt.NDArrayShape,
1252 directions: typ.Optional[pxt.NDArrayAxis] = None,
1253 diff_method: str = "fd",
1254 mode: ModeSpec = "constant",
1255 gpu: bool = False,
1256 dtype: typ.Optional[pxt.DType] = None,
1257 **diff_kwargs,
1258) -> pxt.OpT:
1259 r"""
1260 Jacobian operator.
1261
1262 Notes
1263 -----
1264
1265 This operator computes the first-order partial derivatives of a :math:`D`-dimensional vector-valued signal of
1266 :math:`C` variables or channels :math:`\mathbf{f} = [\mathbf{f}_{0}, \ldots, \mathbf{f}_{C-1}]` with
1267 :math:`\mathbf{f}_{c} \in \mathbb{R}^{N_{0} \times \cdots \times N_{D-1}}`.
1268
1269 The Jacobian of :math:`\mathbf{f}` is computed via the gradient as follows:
1270
1271 .. math::
1272
1273 \mathbf{J} \mathbf{f} = \begin{bmatrix}
1274 (\boldsymbol{\nabla} \mathbf{f}_{0})^{\top} \\
1275 \vdots \\
1276 (\boldsymbol{\nabla} \mathbf{f}_{C-1})^{\top} \\
1277 \end{bmatrix} \in \mathbb{R}^{C \times D \times N_0 \times \cdots \times N_{D-1}}
1278
1279 The partial derivatives can be approximated by `finite differences
1280 <https://en.wikipedia.org/wiki/Finite_difference>`_ via the
1281 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` constructor or by the `Gaussian derivative
1282 <https://www.crisluengo.net/archives/22/>`_ via
1283 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative` constructor.
1284 The parametrization of the partial derivatives can be done via the keyword arguments `\*\*diff_kwargs`, which will
1285 default to the same values as the :py:class:`~pyxu.operator.PartialDerivative` constructor.
1286
1287 **Remark**
1288
1289 Pyxu's convention when it comes to field-vectors, is to work with vectorized arrays. However, the memory order of
1290 these arrays should be `[S_0, ..., S_B, C, N_1, ..., N_D]` shape, with `S_0, ..., S_B` being stacking or batching
1291 dimensions, `C` being the number of variables or channels, and `N_i` being the size of the `i`-th axis of the domain.
1292
1293 Parameters
1294 ----------
1295 dim_shape: NDArrayShape
1296 (C, N_1,...,N_D) input dimensions.
1297 directions: Integer, list[Integer], None
1298 Gradient directions.
1299 Defaults to `None`, which computes the gradient for all directions.
1300 diff_method: "gd", "fd"
1301 Method used to approximate the derivative.
1302 Must be one of:
1303
1304 * 'fd' (default): finite differences
1305 * 'gd': Gaussian derivative
1306 mode: str, list[str]
1307 Boundary conditions.
1308 Multiple forms are accepted:
1309
1310 * str: unique mode shared amongst dimensions.
1311 Must be one of:
1312
1313 * 'constant' (default): zero-padding
1314 * 'wrap'
1315 * 'reflect'
1316 * 'symmetric'
1317 * 'edge'
1318 * tuple[str, ...]: the `d`-th dimension uses ``mode[d]`` as boundary condition.
1319
1320 (See :py:func:`numpy.pad` for details.)
1321 gpu: bool
1322 Input NDArray type (`True` for GPU, `False` for CPU).
1323 Defaults to `False`.
1324 dtype: DType
1325 Working precision of the linear operator.
1326 diff_kwargs: dict
1327 Keyword arguments to parametrize partial derivatives (see
1328 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` and
1329 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative`)
1330
1331 Returns
1332 -------
1333 op: OpT
1334 Jacobian
1335
1336 Example
1337 -------
1338
1339 .. plot::
1340
1341 import numpy as np
1342 import matplotlib.pyplot as plt
1343 from pyxu.operator import Jacobian
1344 from pyxu.util.misc import peaks
1345
1346 x = np.linspace(-2.5, 2.5, 25)
1347 xx, yy = np.meshgrid(x, x)
1348 image = np.tile(peaks(xx, yy), (3, 1, 1))
1349 jac = Jacobian(dim_shape=image.shape)
1350 out = jac(image)
1351 fig, axes = plt.subplots(3, 2, figsize=(10, 15))
1352 for i in range(3):
1353 for j in range(2):
1354 axes[i, j].imshow(out[i, j].T, cmap=["Reds", "Greens", "Blues"][i])
1355 axes[i, j].set_title(f"$\partial I_{{{['R', 'G', 'B'][j]}}}/\partial{{{['x', 'y'][j]}}}$")
1356 plt.suptitle("Jacobian")
1357
1358
1359 See Also
1360 --------
1361 :py:func:`~pyxu.operator.Gradient`,
1362 :py:func:`~pyxu.operator.PartialDerivative`.
1363 """
1364
1365 from collections.abc import Iterable
1366
1367 if directions is not None:
1368 if not isinstance(directions, Iterable):
1369 directions = [
1370 directions,
1371 ]
1372 else:
1373 if isinstance(directions, tuple):
1374 directions = list(directions)
1375 directions = tuple([d - 1 for d in directions])
1376
1377 dim_shape = pxu.as_canonical_shape(dim_shape)
1378 init_kwargs = dict(
1379 dim_shape=dim_shape[1:],
1380 directions=directions,
1381 diff_method=diff_method,
1382 mode=mode,
1383 gpu=gpu,
1384 dtype=dtype,
1385 **diff_kwargs,
1386 )
1387
1388 grad = Gradient(**init_kwargs)
1389 n_channels = dim_shape[0]
1390 if n_channels > 1:
1391 op = pxb.block_diag(
1392 [
1393 grad,
1394 ]
1395 * n_channels
1396 )
1397 else:
1398 op = grad
1399 op._name = "Jacobian"
1400 return op
1401
1402
[docs]
1403def Divergence(
1404 dim_shape: pxt.NDArrayShape,
1405 directions: typ.Optional[pxt.NDArrayAxis] = None,
1406 diff_method: str = "fd",
1407 mode: ModeSpec = "constant",
1408 gpu: bool = False,
1409 dtype: typ.Optional[pxt.DType] = None,
1410 **diff_kwargs,
1411) -> pxt.OpT:
1412 r"""
1413 Divergence operator.
1414
1415 Notes
1416 -----
1417
1418 This operator computes the expansion or outgoingness of a :math:`D`-dimensional vector-valued signal of :math:`C`
1419 variables :math:`\mathbf{f} = [\mathbf{f}_{0}, \ldots, \mathbf{f}_{C-1}]` with :math:`\mathbf{f}_{c} \in
1420 \mathbb{R}^{N_{0} \times \cdots \times N_{D-1}}`.
1421
1422 The Divergence of :math:`\mathbf{f}` is computed via the adjoint of the gradient as follows:
1423
1424 .. math::
1425
1426 \operatorname{Div} \mathbf{f} = \boldsymbol{\nabla}^{\ast} \mathbf{f}
1427 = \begin{bmatrix}
1428 \frac{\partial \mathbf{f}}{\partial x_0} + \cdots + \frac{\partial \mathbf{f}}{\partial x_{D-1}}
1429 \end{bmatrix} \in \mathbb{R}^{N_{0} \times \cdots \times N_{D-1}}
1430
1431 The partial derivatives can be approximated by `finite differences
1432 <https://en.wikipedia.org/wiki/Finite_difference>`_ via the
1433 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` constructor or by the `Gaussian derivative
1434 <https://www.crisluengo.net/archives/22/>`_ via
1435 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative` constructor.
1436 The parametrization of the partial derivatives can be done via the keyword arguments `\*\*diff_kwargs`, which will
1437 default to the same values as the :py:class:`~pyxu.operator.PartialDerivative` constructor.
1438
1439
1440 When using finite differences to compute the Divergence (i.e., ``diff_method = "fd"``), the divergence returns the
1441 adjoint of the gradient in reversed order:
1442
1443 * For ``forward`` type divergence, the adjoint of the gradient of "backward" type is used.
1444 * For ``backward`` type divergence, the adjoint of the gradient of "forward" type is used.
1445
1446 For ``central`` type divergence, and for the Gaussian derivative method (i.e., ``diff_method = "gd"``), the adjoint
1447 of the gradient of "central" type is used (no reversed order).
1448
1449
1450 Parameters
1451 ----------
1452 dim_shape: NDArrayShape
1453 (C, N_1,...,N_D) input dimensions.
1454 directions: Integer, list[Integer], None
1455 Divergence directions.
1456 Defaults to `None`, which computes the divergence for all directions.
1457 diff_method: "gd", "fd"
1458 Method used to approximate the derivative.
1459 Must be one of:
1460
1461 * 'fd' (default): finite differences
1462 * 'gd': Gaussian derivative
1463 mode: str, list[str]
1464 Boundary conditions.
1465 Multiple forms are accepted:
1466
1467 * str: unique mode shared amongst dimensions.
1468 Must be one of:
1469
1470 * 'constant' (default): zero-padding
1471 * 'wrap'
1472 * 'reflect'
1473 * 'symmetric'
1474 * 'edge'
1475 * tuple[str, ...]: the `d`-th dimension uses ``mode[d]`` as boundary condition.
1476
1477 (See :py:func:`numpy.pad` for details.)
1478 gpu: bool
1479 Input NDArray type (`True` for GPU, `False` for CPU).
1480 Defaults to `False`.
1481 dtype: DType
1482 Working precision of the linear operator.
1483 diff_kwargs: dict
1484 Keyword arguments to parametrize partial derivatives (see
1485 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` and
1486 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative`)
1487
1488 Returns
1489 -------
1490 op: OpT
1491 Divergence
1492
1493 Example
1494 -------
1495
1496 .. plot::
1497
1498 import numpy as np
1499 import matplotlib.pyplot as plt
1500 from pyxu.operator import Gradient, Divergence, Laplacian
1501 from pyxu.util.misc import peaks
1502
1503 n = 100
1504 x = np.linspace(-3, 3, n)
1505 xx, yy = np.meshgrid(x, x)
1506 image = peaks(xx, yy)
1507 dim_shape = image.shape # (1000, 1000)
1508 grad = Gradient(dim_shape=dim_shape)
1509 div = Divergence(dim_shape=dim_shape)
1510 # Construct Laplacian via composition
1511 laplacian1 = div * grad
1512 # Compare to default Laplacian
1513 laplacian2 = Laplacian(dim_shape=dim_shape)
1514 output1 = laplacian1(image)
1515 output2 = laplacian2(image)
1516 fig, axes = plt.subplots(1, 2, figsize=(10, 5))
1517 im = axes[0].imshow(np.log(abs(output1)).reshape(*dim_shape))
1518 axes[0].set_title("Laplacian via composition")
1519 plt.colorbar(im, ax=axes[0])
1520 im = axes[1].imshow(np.log(abs(output1)).reshape(*dim_shape))
1521 axes[1].set_title("Default Laplacian")
1522 plt.colorbar(im, ax=axes[1])
1523
1524
1525 See Also
1526 --------
1527 :py:func:`~pyxu.operator.Gradient`,
1528 :py:func:`~pyxu.operator.PartialDerivative`.
1529 """
1530 if diff_method == "fd":
1531 change = {"central": "central", "forward": "backward", "backward": "forward"}
1532 scheme = diff_kwargs.get("scheme", "central")
1533 if isinstance(scheme, str):
1534 diff_kwargs.update({"scheme": change[scheme]})
1535 elif isinstance(scheme, cabc.Sequence):
1536 new_scheme = list(scheme)
1537 for i in range(len(new_scheme)):
1538 new_scheme[i] = change[scheme[i]]
1539 diff_kwargs.update(dict(scheme=new_scheme))
1540
1541 init_kwargs = dict(
1542 diff_method=diff_method,
1543 mode=mode,
1544 gpu=gpu,
1545 dtype=dtype,
1546 **diff_kwargs,
1547 )
1548
1549 # Add dummy parameters for artificial dimension
1550 for key in init_kwargs.keys():
1551 param = init_kwargs[key]
1552 if isinstance(param, cabc.Sequence):
1553 if not isinstance(param, str):
1554 param = list(param)
1555 param.insert(0, "dummy")
1556 init_kwargs.update({key: param})
1557
1558 dim_shape = pxu.as_canonical_shape(dim_shape)
1559 directions = tuple([i for i in range(1, len(dim_shape))]) if directions is None else directions
1560 assert all(
1561 [direction > 0 for direction in directions]
1562 ), "The first direction corresponds to the vector values, see Documentation."
1563 n_dir = len(directions)
1564
1565 pds = pxb.block_diag(
1566 [Gradient(dim_shape=dim_shape[1:], directions=(direction - 1,), **init_kwargs) for direction in directions],
1567 )
1568 op = pxlr.Sum(dim_shape=(n_dir,) + dim_shape[1:], axis=0) * pds.reshape((n_dir,) + dim_shape[1:])
1569 op._name = "Divergence"
1570 return op.reshape(dim_shape[1:])
1571
1572
[docs]
1573def Hessian(
1574 dim_shape: pxt.NDArrayShape,
1575 directions: typ.Union[
1576 str,
1577 cabc.Sequence[pxt.Integer, pxt.Integer],
1578 cabc.Sequence[cabc.Sequence[pxt.Integer, pxt.Integer], ...],
1579 ] = "all",
1580 diff_method: str = "fd",
1581 mode: ModeSpec = "constant",
1582 gpu: bool = False,
1583 dtype: typ.Optional[pxt.DType] = None,
1584 **diff_kwargs,
1585) -> pxt.OpT:
1586 r"""
1587 Hessian operator.
1588
1589 Notes
1590 -----
1591
1592 The Hessian matrix or Hessian of a :math:`D`-dimensional signal :math:`\mathbf{f} \in \mathbb{R}^{N_0 \times \cdots
1593 \times N_{D-1}}` is the square matrix of second-order partial derivatives:
1594
1595 .. math::
1596
1597 \mathbf{H} \mathbf{f} = \begin{bmatrix}
1598 \dfrac{ \partial^{2}\mathbf{f} }{ \partial x_{0}^{2} } & \dfrac{ \partial^{2}\mathbf{f} }{ \partial x_{0}\,\partial x_{1} } & \cdots & \dfrac{ \partial^{2}\mathbf{f} }{ \partial x_{0} \, \partial x_{D-1} } \\
1599 \dfrac{ \partial^{2}\mathbf{f} }{ \partial x_{1} \, \partial x_{0} } & \dfrac{ \partial^{2}\mathbf{f} }{ \partial x_{1}^{2} } & \cdots & \dfrac{ \partial^{2}\mathbf{f} }{\partial x_{1} \,\partial x_{D-1}} \\
1600 \vdots & \vdots & \ddots & \vdots \\
1601 \dfrac{ \partial^{2}\mathbf{f} }{ \partial x_{D-1} \, \partial x_{0} } & \dfrac{ \partial^{2}\mathbf{f} }{ \partial x_{D-1} \, \partial x_{1} } & \cdots & \dfrac{ \partial^{2}\mathbf{f} }{ \partial x_{D-1}^{2}}
1602 \end{bmatrix}
1603
1604 The partial derivatives can be approximated by `finite differences
1605 <https://en.wikipedia.org/wiki/Finite_difference>`_ via the
1606 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` constructor or by the `Gaussian derivative
1607 <https://www.crisluengo.net/archives/22/>`_ via
1608 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative` constructor.
1609 The parametrization of the partial derivatives can be done via the keyword arguments `\*\*diff_kwargs`, which will
1610 default to the same values as the :py:class:`~pyxu.operator.PartialDerivative` constructor.
1611
1612 The Hessian being symmetric, only the upper triangular part at most needs to be computed.
1613 Due to the (possibly) large size of the full Hessian, 4 different options are handled:
1614
1615 * [mode 0] ``directions`` is an integer, e.g.: ``directions=0``
1616
1617 .. math::
1618
1619 \partial^{2}\mathbf{f}/\partial x_{0}^{2}.
1620
1621 * [mode 1] ``directions`` is a tuple of length 2, e.g.: ``directions=(0,1)``
1622
1623 .. math::
1624
1625 \partial^{2}\mathbf{f}/\partial x_{0}\partial x_{1}.
1626
1627 * [mode 2] ``directions`` is a tuple of tuples, e.g.: ``directions=((0,0), (0,1))``
1628
1629 .. math::
1630
1631 \left(\frac{ \partial^{2}\mathbf{f} }{ \partial x_{0}^{2} }, \frac{ \partial^{2}\mathbf{f} }{ \partial x_{0}\partial x_{1} }\right).
1632
1633 * [mode 3] ``directions = 'all'`` (default), computes the Hessian for all directions (only the
1634 upper triangular part of the Hessian matrix), in row order, i.e.:
1635
1636 .. math::
1637
1638 \left(\frac{ \partial^{2}\mathbf{f} }{ \partial x_{0}^{2} }, \frac{ \partial^{2}\mathbf{f} }
1639 { \partial x_{0}\partial x_{1} }, \, \ldots , \, \frac{ \partial^{2}\mathbf{f} }{ \partial x_{D-1}^{2} }\right).
1640
1641 The shape of the output :py:class:`~pyxu.abc.LinOp` depends on the number of computed directions; by
1642 default (all directions), we have :math:`\mathbf{H} \mathbf{f} \in \mathbb{R}^{\frac{D(D-1)}{2} \times N_0 \times
1643 \cdots \times N_{D-1}}`.
1644
1645
1646 Parameters
1647 ----------
1648 dim_shape: NDArrayShape
1649 (N_1,...,N_D) input dimensions.
1650 directions: Integer, (Integer, Integer), ((Integer, Integer), ..., (Integer, Integer)), 'all'
1651 Hessian directions.
1652 Defaults to `all`, which computes the Hessian for all directions. (See ``Notes``.)
1653 diff_method: "gd", "fd"
1654 Method used to approximate the derivative.
1655 Must be one of:
1656
1657 * 'fd' (default): finite differences
1658 * 'gd': Gaussian derivative
1659 mode: str, list[str]
1660 Boundary conditions.
1661 Multiple forms are accepted:
1662
1663 * str: unique mode shared amongst dimensions.
1664 Must be one of:
1665
1666 * 'constant' (default): zero-padding
1667 * 'wrap'
1668 * 'reflect'
1669 * 'symmetric'
1670 * 'edge'
1671 * tuple[str, ...]: the `d`-th dimension uses ``mode[d]`` as boundary condition.
1672
1673 (See :py:func:`numpy.pad` for details.)
1674 gpu: bool
1675 Input NDArray type (`True` for GPU, `False` for CPU).
1676 Defaults to `False`.
1677 dtype: DType
1678 Working precision of the linear operator.
1679 diff_kwargs: dict
1680 Keyword arguments to parametrize partial derivatives (see
1681 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` and
1682 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative`)
1683
1684 Returns
1685 -------
1686 op: OpT
1687 Hessian
1688
1689 Example
1690 -------
1691
1692 .. plot::
1693
1694 import numpy as np
1695 import matplotlib.pyplot as plt
1696 from pyxu.operator import Hessian, PartialDerivative
1697 from pyxu.util.misc import peaks
1698
1699 n = 100
1700 x = np.linspace(-3, 3, n)
1701 xx, yy = np.meshgrid(x, x)
1702 image = peaks(xx, yy)
1703 dim_shape = image.shape # (1000, 1000)
1704
1705 # Instantiate Hessian operator
1706 hessian = Hessian(dim_shape=dim_shape, directions="all")
1707 # Compute Hessian
1708 d2f_dx2, d2f_dxdy, d2f_dy2 = hessian(image)
1709
1710 # Plot
1711 fig, axs = plt.subplots(1, 4, figsize=(20, 4))
1712 im = axs[0].imshow(image)
1713 plt.colorbar(im, ax=axs[0])
1714 axs[0].set_title("Image")
1715 axs[0].axis("off")
1716
1717 im = axs[1].imshow(d2f_dx2)
1718 plt.colorbar(im, ax=axs[1])
1719 axs[1].set_title(r"$\partial^{2} f/ \partial x^{2}$")
1720 axs[1].axis("off")
1721
1722 im = axs[2].imshow(d2f_dxdy)
1723 plt.colorbar(im, ax=axs[2])
1724 axs[2].set_title(r"$\partial^{2} f/ \partial x\partial y$")
1725 axs[2].axis("off")
1726
1727 im = axs[3].imshow(d2f_dy2)
1728 plt.colorbar(im, ax=axs[3])
1729 axs[3].set_title(r"$\partial^{2} f/ \partial y^{2}$")
1730 axs[3].axis("off")
1731
1732 See Also
1733 --------
1734 :py:class:`~pyxu.operator.PartialDerivative`,
1735 :py:class:`~pyxu.operator.Gradient`,
1736 :py:class:`~pyxu.operator.Laplacian`.
1737 """
1738 # We assume Schwarz's theorem holds and thus the symmetry of second derivatives.
1739 # For this reason, when directions == `all`, only the upper triangular part of the Hessian is
1740 # returned.
1741 # However, this might not hold for non-trivial padding conditions, and the user can demand all
1742 # Hessian components via the `directions` 2nd mode (see ``Notes``).
1743
1744 dim_shape = pxu.as_canonical_shape(dim_shape)
1745 order, sampling, param1, param2, _ = _sanitize_init_kwargs(
1746 order=(1,) * len(dim_shape),
1747 diff_method=diff_method,
1748 diff_kwargs=diff_kwargs,
1749 dim_shape=dim_shape,
1750 sampling=diff_kwargs.get("sampling", 1.0),
1751 )
1752
1753 directions, order = _StackDiffHelper._check_directions_and_order(dim_shape, directions)
1754
1755 # If diff_method is "fd" default to "central" for diag components and "forward" for off-diag.
1756 if (diff_method == "fd") and (diff_kwargs.get("scheme", None) is None):
1757 param1 = [("central", "central") if o == 2 else param1 for o in order]
1758
1759 op = _StackDiffHelper._stack_diff_ops(
1760 dim_shape=dim_shape,
1761 directions=directions,
1762 diff_method=diff_method,
1763 order=order,
1764 param1=param1,
1765 param2=param2,
1766 mode=mode,
1767 gpu=gpu,
1768 dtype=dtype,
1769 sampling=sampling,
1770 )
1771 op._name = "Hessian"
1772 return op
1773
1774
[docs]
1775def Laplacian(
1776 dim_shape: pxt.NDArrayShape,
1777 directions: typ.Optional[pxt.NDArrayAxis] = None,
1778 diff_method: str = "fd",
1779 mode: ModeSpec = "constant",
1780 gpu: bool = False,
1781 dtype: typ.Optional[pxt.DType] = None,
1782 **diff_kwargs,
1783) -> pxt.OpT:
1784 r"""
1785 Laplacian operator.
1786
1787 Notes
1788 -----
1789
1790 The Laplacian of a :math:`D`-dimensional signal :math:`\mathbf{f} \in \mathbb{R}^{N_0 \times \cdots \times N_{D-1}}`
1791 is the sum of second-order partial derivatives across all input directions:
1792
1793 .. math::
1794
1795 \sum_{d = 0}^{D-1} \dfrac{ \partial^{2}\mathbf{f} }{ \partial x_{d}^{2} }
1796
1797 The partial derivatives can be approximated by `finite differences
1798 <https://en.wikipedia.org/wiki/Finite_difference>`_ via the
1799 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` constructor or by the `Gaussian derivative
1800 <https://www.crisluengo.net/archives/22/>`_ via
1801 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative` constructor.
1802 The parametrization of the partial derivatives can be done via the keyword arguments `\*\*diff_kwargs`, which will
1803 default to `scheme='central'` and `accuracy=2` for `diff_method='fd'` (finite difference), and the same values as
1804 the :py:class:`~pyxu.operator.PartialDerivative` constructor for `diff_method='gd'` (gaussian
1805 derivative).
1806
1807 Parameters
1808 ----------
1809 dim_shape: NDArrayShape
1810 (N_1,...,N_D) input dimensions.
1811 directions: Integer, list[Integer], None
1812 Laplacian directions. Defaults to `None`, which computes the Laplacian with all directions.
1813 diff_method: "gd", "fd"
1814 Method used to approximate the derivative.
1815 Must be one of:
1816
1817 * 'fd' (default): finite differences
1818 * 'gd': Gaussian derivative
1819 mode: str, list[str]
1820 Boundary conditions.
1821 Multiple forms are accepted:
1822
1823 * str: unique mode shared amongst dimensions.
1824 Must be one of:
1825
1826 * 'constant' (default): zero-padding
1827 * 'wrap'
1828 * 'reflect'
1829 * 'symmetric'
1830 * 'edge'
1831 * tuple[str, ...]: the `d`-th dimension uses ``mode[d]`` as boundary condition.
1832
1833 (See :py:func:`numpy.pad` for details.)
1834 gpu: bool
1835 Input NDArray type (`True` for GPU, `False` for CPU).
1836 Defaults to `False`.
1837 dtype: DType
1838 Working precision of the linear operator.
1839 diff_kwargs: dict
1840 Keyword arguments to parametrize partial derivatives (see
1841 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` and
1842 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative`)
1843
1844 Returns
1845 -------
1846 op: OpT
1847 Laplacian
1848
1849 Example
1850 -------
1851
1852 .. plot::
1853
1854 import numpy as np
1855 import matplotlib.pyplot as plt
1856 from pyxu.operator import Laplacian
1857 from pyxu.util.misc import peaks
1858
1859 # Define input image
1860 n = 100
1861 x = np.linspace(-3, 3, n)
1862 xx, yy = np.meshgrid(x, x)
1863 image = peaks(xx, yy)
1864
1865 dim_shape = image.shape # (1000, 1000)
1866 # Compute Laplacian
1867 laplacian = Laplacian(dim_shape=dim_shape)
1868 output = laplacian(image) # shape = (1, 1000, 1000)
1869
1870 # Plot
1871 fig, axs = plt.subplots(1, 2, figsize=(10, 4))
1872 im = axs[0].imshow(image)
1873 plt.colorbar(im, ax=axs[0])
1874 axs[0].set_title("Image")
1875 axs[0].axis("off")
1876
1877 im = axs[1].imshow(output.squeeze())
1878 plt.colorbar(im, ax=axs[1])
1879 axs[1].set_title(r"$\partial^{2} f/ \partial x^{2}+\partial^{2} f/ \partial y^{2}$")
1880 axs[1].axis("off")
1881
1882 fig.show()
1883
1884 See Also
1885 --------
1886 :py:class:`~pyxu.operator.PartialDerivative`,
1887 :py:class:`~pyxu.operator.Gradient`,
1888 :py:class:`~pyxu.operator.Hessian`.
1889 """
1890 dim_shape = pxu.as_canonical_shape(dim_shape)
1891 ndims = len(dim_shape)
1892 directions = tuple([i for i in range(len(dim_shape))]) if directions is None else directions
1893 directions = [(i, i) for i in range(ndims) if i in directions]
1894 pds = Hessian(
1895 dim_shape=dim_shape,
1896 directions=directions,
1897 diff_method=diff_method,
1898 mode=mode,
1899 gpu=gpu,
1900 dtype=dtype,
1901 **diff_kwargs,
1902 )
1903 op = pxlr.Sum(dim_shape=(ndims,) + dim_shape, axis=0) * pds
1904 op._name = "Laplacian"
1905 return op.reshape(op.dim_shape)
1906
1907
[docs]
1908def DirectionalDerivative(
1909 dim_shape: pxt.NDArrayShape,
1910 order: pxt.Integer,
1911 directions: typ.Union[pxt.NDArray, typ.Sequence[pxt.NDArray]],
1912 diff_method: str = "fd",
1913 mode: ModeSpec = "constant",
1914 **diff_kwargs,
1915) -> pxt.OpT:
1916 r"""
1917 Directional derivative operator.
1918
1919 Notes
1920 -----
1921 The **first-order** ``DirectionalDerivative`` of a :math:`D`-dimensional signal :math:`\mathbf{f} \in
1922 \mathbb{R}^{N_0 \times \cdots \times N_{D-1}}` applies a derivative along the direction specified by a constant
1923 unitary vector :math:`\mathbf{v} \in \mathbb{R}^D`:
1924
1925 .. math::
1926
1927 \boldsymbol{\nabla}_\mathbf{v} \mathbf{f} = \sum_{i=0}^{D-1} v_i \frac{\partial \mathbf{f}}{\partial x_i} \in
1928 \mathbb{R}^{N_0 \times \cdots \times N_{D-1}}
1929
1930 or along spatially-varying directions :math:`\mathbf{v} = [\mathbf{v}_0, \ldots , \mathbf{v}_{D-1}]^\top \in
1931 \mathbb{R}^{D \times N_0 \times \cdots \times N_{D-1} }` where each direction :math:`\mathbf{v}_{\cdot, i_0, \ldots
1932 , i_{D-1}} \in \mathbb{R}^D` for any :math:`0 \leq i_d \leq N_d-1` with :math:`0 \leq d \leq D-1` is a unitary
1933 vector:
1934
1935 .. math::
1936
1937 \boldsymbol{\nabla}_\mathbf{v} \mathbf{f} = \sum_{i=0}^{D-1} \mathbf{v}_i \odot
1938 \frac{\partial \mathbf{f}}{\partial x_i} \in \mathbb{R}^{N_0 \times \cdots \times N_{D-1}},
1939
1940 where :math:`\odot` denotes the Hadamard (elementwise) product.
1941
1942 Note that choosing :math:`\mathbf{v}= \mathbf{e}_d \in \mathbb{R}^D` (the :math:`d`-th canonical basis vector)
1943 amounts to the first-order :py:func:`~pyxu.operator.PartialDerivative` operator applied along axis
1944 :math:`d`.
1945
1946 The **second-order** ``DirectionalDerivative`` :math:`\boldsymbol{\nabla}^2_{\mathbf{v}_{1, 2}}` is obtained by
1947 composing the first-order directional derivatives :math:`\boldsymbol{\nabla}_{\mathbf{v}_{1}}` and
1948 :math:`\boldsymbol{\nabla}_{\mathbf{v}_{2}}`:
1949
1950 .. math::
1951
1952 \boldsymbol{\nabla}_{\mathbf{v}_{1}} (\boldsymbol{\nabla}_{\mathbf{v}_{2}} \mathbf{f}) =
1953 \boldsymbol{\nabla}_{\mathbf{v}_{1}} (\sum_{i=0}^{D-1} {v_{2}}_{i} \frac{\partial \mathbf{f}}{\partial x_i}) =
1954 \sum_{j=0}^{D-1} {v_{1}}_{j} \frac{\partial}{\partial x_j} (\sum_{i=0}^{D-1} {v_{2}}_{i} \frac{\partial \mathbf{f}}{\partial x_i}) =
1955 \sum_{i, j=0}^{D-1} {v_{1}}_{j} {v_{2}}_{i} \frac{\partial}{\partial x_j} \frac{\partial}{\partial x_i}\mathbf{f} =
1956 \mathbf{v}_{1}^{\top}\mathbf{H}\mathbf{f}\mathbf{v}_{2},
1957
1958 where :math:`\mathbf{H}` is the discrete Hessian operator, implemented via
1959 :py:class:`~pyxu.operator.Hessian`.
1960
1961 Higher-order ``DirectionalDerivative`` :math:`\boldsymbol{\nabla}^{N}_\mathbf{v}` can be obtained by composing the
1962 first-order directional derivative :math:`\boldsymbol{\nabla}_\mathbf{v}` :math:`N` times.
1963
1964
1965 .. warning::
1966
1967 - :py:func:`~pyxu.operator.DirectionalDerivative` instances are **not array module-agnostic**: they
1968 will only work with NDArrays belonging to the same array module as ``directions``. Inner
1969 computations may recast input arrays when the precision of ``directions`` does not match the user-requested
1970 precision.
1971 - ``directions`` are always normalized to be unit vectors.
1972
1973 Parameters
1974 ----------
1975 dim_shape: NDArrayShape
1976 Shape of the input array.
1977 order: Integer
1978 Which directional derivative (restricted to 1: First or 2: Second, see ``Notes``).
1979 directions: NDArray, list
1980 For ``order=1``, it can be a single direction (array of size :math:`(D,)`, where :math:`D` is the number of
1981 axes) or spatially-varying directions:
1982
1983 * array of size :math:`(D, N_0 \times \ldots \times N_{D-1})` for ``order=1``, i.e., one direction per element
1984 in the gradient, and,
1985
1986 * array of size :math:`(D * (D + 1) / 2, N_0 \times \ldots \times N_{D-1})` for ``order=2``, i.e., one direction
1987 per element in the Hessian.
1988
1989 For ``order=2``, it can be a tuple/list of two single directions or spatially-varying dimensions of the same
1990 shape, which will compute :math:`\mathbf{v}_{1}^{\top}\mathbf{H}\mathbf{f}\mathbf{v}_{2}` or a single direction,
1991 as for ``order=1``, which will compute :math:`\mathbf{v}_{1}^{\top}\mathbf{H}\mathbf{f}\mathbf{v}_{1}`. Note
1992 that for ``order=2``, even though directions are spatially-varying, no differentiation is performed for this
1993 parameter.
1994 diff_method: "gd", "fd"
1995 Method used to approximate the derivative. Must be one of:
1996
1997 * 'fd' (default): finite differences
1998 * 'gd': Gaussian derivative
1999 mode: str, list[str]
2000 Boundary conditions.
2001 Multiple forms are accepted:
2002
2003 * str: unique mode shared amongst dimensions.
2004 Must be one of:
2005
2006 * 'constant' (default): zero-padding
2007 * 'wrap'
2008 * 'reflect'
2009 * 'symmetric'
2010 * 'edge'
2011 * tuple[str, ...]: the `d`-th dimension uses ``mode[d]`` as boundary condition.
2012
2013 (See :py:func:`numpy.pad` for details.)
2014 diff_kwargs: dict
2015 Keyword arguments to parametrize partial derivatives (see
2016 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` and
2017 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative`)
2018
2019 Returns
2020 -------
2021 op: OpT
2022 Directional derivative
2023
2024 Example
2025 -------
2026
2027 .. plot::
2028
2029 import numpy as np
2030 import matplotlib.pyplot as plt
2031 from pyxu.operator import DirectionalDerivative
2032 from pyxu.util.misc import peaks
2033
2034 x = np.linspace(-2.5, 2.5, 25)
2035 xx, yy = np.meshgrid(x, x)
2036 z = peaks(xx, yy)
2037 directions = np.zeros(shape=(2, z.size))
2038 directions[0, : z.size // 2] = 1
2039 directions[1, z.size // 2:] = 1
2040 dop = DirectionalDerivative(dim_shape=z.shape, order=1, directions=directions)
2041 out = dop(z)
2042 dop2 = DirectionalDerivative(dim_shape=z.shape, order=2, directions=directions)
2043 out2 = dop2(z)
2044 fig, axs = plt.subplots(1, 3, figsize=(15, 5))
2045 axs = np.ravel(axs)
2046 h = axs[0].pcolormesh(xx, yy, z, shading="auto")
2047 axs[0].quiver(x, x, directions[1].reshape(xx.shape), directions[0].reshape(xx.shape))
2048 plt.colorbar(h, ax=axs[0])
2049 axs[0].set_title("Signal and directions of first derivatives")
2050
2051 h = axs[1].pcolormesh(xx, yy, out.squeeze(), shading="auto")
2052 plt.colorbar(h, ax=axs[1])
2053 axs[1].set_title("First-order directional derivatives")
2054
2055 h = axs[2].pcolormesh(xx, yy, out2.squeeze(), shading="auto")
2056 plt.colorbar(h, ax=axs[2])
2057 axs[2].set_title("Second-order directional derivative")
2058
2059 See Also
2060 --------
2061 :py:func:`~pyxu.operator.Gradient`,
2062 :py:func:`~pyxu.operator.DirectionalGradient`
2063 """
2064 dim_shape = pxu.as_canonical_shape(dim_shape)
2065 ndim = len(dim_shape)
2066 # For first directional derivative, ndim_diff == number of elements in gradient
2067 # For second directional derivative, ndim_diff == number of unique elements in Hessian
2068 ndim_diff = ndim if order == 1 else ndim * (ndim + 1) // 2
2069
2070 assert order in [1, 2], "`order` should be either 1 or 2"
2071
2072 # Ensure correct format of `directions`
2073 if order == 1:
2074 assert not isinstance(directions, cabc.Sequence), (
2075 "`directions` for first directional derivative should be an " "NDArray"
2076 )
2077 diff_op = Gradient
2078 else:
2079 if isinstance(directions, cabc.Sequence):
2080 error_msg = (
2081 "`directions` for second directional derivative should be an NDArray or a tuple/list with two "
2082 "NDArrays of the same shape"
2083 )
2084 assert len(directions) == 2, error_msg
2085 directions, directions2 = directions
2086 assert directions.shape == directions2.shape, error_msg
2087 else:
2088 directions2 = directions
2089 diff_op = Hessian
2090
2091 assert directions.shape[0] == ndim, "The length of `directions` should match `len(dim_shape)`"
2092
2093 xp = pxu.get_array_module(directions)
2094 gpu = xp == pxd.NDArrayInfo.CUPY.module()
2095
2096 dtype = directions.dtype
2097
2098 diff = diff_op(
2099 dim_shape=dim_shape,
2100 diff_method=diff_method,
2101 mode=mode,
2102 gpu=gpu,
2103 dtype=dtype,
2104 **diff_kwargs,
2105 )
2106 # normalize directions to unit norm
2107 norm_dirs = (directions / xp.linalg.norm(directions, axis=0, keepdims=True)).astype(dtype)
2108
2109 if order == 1:
2110 op_name = "FirstDirectionalDerivative"
2111 else: # order == 2
2112 op_name = "SecondDirectionalDerivative"
2113 # Compute directions' outer product (see Notes)
2114
2115 norm_dirs2 = (directions2 / xp.linalg.norm(directions2, axis=0, keepdims=True)).astype(dtype)
2116 norm_dirs = norm_dirs[:, None, ...] * norm_dirs2[None, ...]
2117 # Multiply off-diag components x2 and use only triangular upper part of the outer product
2118 if ndim > 1:
2119 # (fancy nd indexing not supported in Dask)
2120 norm_dirs = norm_dirs.reshape(ndim**2, *norm_dirs.shape[2:])
2121 dummy_mat = np.arange(ndim**2).reshape(ndim, ndim)
2122 off_diag_inds = dummy_mat[np.triu_indices(ndim, k=1)].ravel()
2123 norm_dirs[off_diag_inds] *= 2
2124 inds = dummy_mat[np.triu_indices(ndim, k=0)].ravel()
2125 norm_dirs = norm_dirs[inds]
2126 else:
2127 norm_dirs = norm_dirs.ravel()
2128
2129 if directions.ndim == 1:
2130 diag = xp.tile(norm_dirs, dim_shape + (1,)).transpose().reshape(-1, *dim_shape)
2131 else:
2132 diag = norm_dirs.reshape(-1, *dim_shape)
2133
2134 dop = pxlb.DiagonalOp(diag)
2135 sop = pxlr.Sum(dim_shape=(ndim_diff,) + dim_shape, axis=0)
2136 sqop = pxm.SqueezeAxes(dim_shape=sop.codim_shape, axes=0)
2137 op = sqop * sop * dop * diff
2138 dop_compute = pxlb.DiagonalOp(pxu.compute(diag))
2139 op_compute = sqop * sop * dop_compute * diff
2140
2141 def op_svdvals(_, **kwargs) -> pxt.NDArray:
2142 return op_compute.svdvals(**kwargs)
2143
2144 setattr(op, "svdvals", types.MethodType(op_svdvals, op))
2145 op._name = op_name
2146 return op
2147
2148
[docs]
2149def DirectionalGradient(
2150 dim_shape: pxt.NDArrayShape,
2151 directions: cabc.Sequence[pxt.NDArray],
2152 diff_method: str = "fd",
2153 mode: ModeSpec = "constant",
2154 **diff_kwargs,
2155) -> pxt.OpT:
2156 r"""
2157 Directional gradient operator.
2158
2159 Notes
2160 -----
2161 The ``DirectionalGradient`` of a :math:`D`-dimensional signal :math:`\mathbf{f} \in \mathbb{R}^{N_0 \times \cdots
2162 \times N_{D-1}}` stacks the directional derivatives of :math:`\mathbf{f}` along a list of :math:`m` directions
2163 :math:`\mathbf{v}_i` for :math:`1 \leq i \leq m`:
2164
2165 .. math::
2166
2167 \boldsymbol{\nabla}_{\mathbf{v}_1, \ldots ,\mathbf{v}_m} \mathbf{f} = \begin{bmatrix}
2168 \boldsymbol{\nabla}_{\mathbf{v}_1} \\
2169 \vdots\\
2170 \boldsymbol{\nabla}_{\mathbf{v}_m}\\
2171 \end{bmatrix} \mathbf{f} \in \mathbb{R}^{m \times N_0 \times \cdots \times N_{D-1}},
2172
2173 where :math:`\boldsymbol{\nabla}_{\mathbf{v}_i}` is the first-order directional derivative along
2174 :math:`\mathbf{v}_i` implemented with :py:func:`~pyxu.operator.DirectionalDerivative`, with
2175 :math:`\mathbf{v}_i \in \mathbb{R}^D` or :math:`\mathbf{v}_i \in \mathbb{R}^{D \times N_0 \times \cdots \times
2176 N_{D-1}}`.
2177
2178 Note that choosing :math:`m=D` and :math:`\mathbf{v}_i = \mathbf{e}_i \in \mathbb{R}^D` (the :math:`i`-th canonical
2179 basis vector) amounts to the :py:func:`~pyxu.operator.Gradient` operator.
2180
2181 Parameters
2182 ----------
2183 dim_shape: NDArrayShape
2184 Shape of the input array.
2185 directions: list[NDArray]
2186 List of directions, either constant (array of size :math:`(D,)`) or spatially-varying (array of size :math:`(D,
2187 N_0, \ldots, N_{D-1})`)
2188 diff_method: "gd", "fd"
2189 Method used to approximate the derivative. Must be one of:
2190
2191 * 'fd' (default): finite differences
2192 * 'gd': Gaussian derivative
2193 mode: str, list[str]
2194 Boundary conditions.
2195 Multiple forms are accepted:
2196
2197 * str: unique mode shared amongst dimensions.
2198 Must be one of:
2199
2200 * 'constant' (default): zero-padding
2201 * 'wrap'
2202 * 'reflect'
2203 * 'symmetric'
2204 * 'edge'
2205 * tuple[str, ...]: the `d`-th dimension uses ``mode[d]`` as boundary condition.
2206
2207 (See :py:func:`numpy.pad` for details.)
2208 diff_kwargs: dict
2209 Keyword arguments to parametrize partial derivatives (see
2210 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` and
2211 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative`)
2212
2213 Returns
2214 -------
2215 op: OpT
2216 Directional gradient
2217
2218
2219 Example
2220 -------
2221
2222 .. plot::
2223
2224 import numpy as np
2225 import matplotlib.pyplot as plt
2226 from pyxu.operator import DirectionalGradient
2227 from pyxu.util.misc import peaks
2228
2229 x = np.linspace(-2.5, 2.5, 25)
2230 xx, yy = np.meshgrid(x, x)
2231 z = peaks(xx, yy)
2232 directions1 = np.zeros(shape=(2, z.size))
2233 directions1[0, :z.size // 2] = 1
2234 directions1[1, z.size // 2:] = 1
2235 directions2 = np.zeros(shape=(2, z.size))
2236 directions2[1, :z.size // 2] = -1
2237 directions2[0, z.size // 2:] = -1
2238 dim_shape = z.shape
2239 dop = DirectionalGradient(dim_shape=dim_shape, directions=[directions1, directions2])
2240 out = dop(z)
2241 plt.figure()
2242 h = plt.pcolormesh(xx, yy, z, shading='auto')
2243 plt.quiver(x, x, directions1[1].reshape(dim_shape), directions1[0].reshape(xx.shape))
2244 plt.quiver(x, x, directions2[1].reshape(dim_shape), directions2[0].reshape(xx.shape), color='red')
2245 plt.colorbar(h)
2246 plt.title(r'Signal $\mathbf{f}$ and directions of derivatives')
2247 plt.figure()
2248 h = plt.pcolormesh(xx, yy, out[0], shading='auto')
2249 plt.colorbar(h)
2250 plt.title(r'$\nabla_{\mathbf{v}_0} \mathbf{f}$')
2251 plt.figure()
2252 h = plt.pcolormesh(xx, yy, out[1], shading='auto')
2253 plt.colorbar(h)
2254 plt.title(r'$\nabla_{\mathbf{v}_1} \mathbf{f}$')
2255
2256 See Also
2257 --------
2258 :py:func:`~pyxu.operator.Gradient`,
2259 :py:func:`~pyxu.operator.DirectionalDerivative`
2260 """
2261
2262 diag_ops = []
2263 diag_ops_compute = []
2264 dim_shape = pxu.as_canonical_shape(dim_shape)
2265 ndim = len(dim_shape)
2266 assert isinstance(directions, cabc.Sequence)
2267
2268 xp = pxu.get_array_module(directions[0])
2269 gpu = xp == pxd.NDArrayInfo.CUPY.module()
2270 dtype = directions[0].dtype
2271
2272 grad = Gradient(
2273 dim_shape=dim_shape,
2274 diff_method=diff_method,
2275 mode=mode,
2276 gpu=gpu,
2277 dtype=dtype,
2278 **diff_kwargs,
2279 )
2280 sop = pxlr.Sum(
2281 dim_shape=(
2282 len(directions),
2283 ndim,
2284 )
2285 + dim_shape,
2286 axis=1,
2287 )
2288 for direction in directions:
2289 # normalize directions to unit norm
2290 norm_dirs = (direction / xp.linalg.norm(direction, axis=0, keepdims=True)).astype(dtype)
2291 if direction.ndim == 1:
2292 diag = xp.tile(norm_dirs, dim_shape + (1,)).transpose().reshape(-1, *dim_shape)
2293 else:
2294 diag = norm_dirs.reshape(-1, *dim_shape)
2295
2296 diag_ops.append(pxlb.DiagonalOp(diag))
2297 diag_ops_compute.append(pxlb.DiagonalOp(pxu.compute(diag)))
2298
2299 dop = pxb.stack(diag_ops)
2300 dop_compute = pxb.stack(diag_ops_compute)
2301
2302 sqop = pxm.SqueezeAxes(dim_shape=sop.codim_shape, axes=1)
2303
2304 op = sqop * sop * dop * grad
2305 op_compute = sqop * sop * dop_compute * grad
2306
2307 def op_svdvals(_, **kwargs) -> pxt.NDArray:
2308 return op_compute.svdvals(**kwargs)
2309
2310 setattr(op, "svdvals", types.MethodType(op_svdvals, op))
2311
2312 op._name = "DirectionalGradient"
2313 return op
2314
2315
[docs]
2316def DirectionalLaplacian(
2317 dim_shape: pxt.NDArrayShape,
2318 directions: cabc.Sequence[pxt.NDArray],
2319 weights: typ.Iterable = None,
2320 diff_method: str = "fd",
2321 mode: ModeSpec = "constant",
2322 **diff_kwargs,
2323) -> pxt.OpT:
2324 r"""
2325 Directional Laplacian operator.
2326
2327 Sum of the second directional derivatives of a multi-dimensional array (at least two dimensions are required) along
2328 multiple ``directions`` for each entry of the array.
2329
2330 Notes
2331 -----
2332
2333 The ``DirectionalLaplacian`` of a :math:`D`-dimensional signal :math:`\mathbf{f} \in \mathbb{R}^{N_0 \times \cdots
2334 \times N_{D-1}}` sums the second-order directional derivatives of :math:`\mathbf{f}` along a list of :math:`m`
2335 directions :math:`\mathbf{v}_i` for :math:`1 \leq i \leq m`:
2336
2337 .. math::
2338
2339 \boldsymbol{\Delta}_{\mathbf{v}_1, \ldots ,\mathbf{v}_m} \mathbf{f} = \sum_{i=1}^m
2340 \boldsymbol{\nabla}^2_{\mathbf{v}_i} \mathbf{f} \in \mathbb{R}^{N_0 \times \cdots \times N_{D-1}},
2341
2342 where :math:`\boldsymbol{\nabla}^2_{\mathbf{v}_i}` is the second-order directional derivative along
2343 :math:`\mathbf{v}_i` implemented with :py:func:`~pyxu.operator.DirectionalDerivative`.
2344
2345 Note that choosing :math:`m=D` and :math:`\mathbf{v}_i = \mathbf{e}_i \in \mathbb{R}^D` (the :math:`i`-th canonical
2346 basis vector) amounts to the :py:func:`~pyxu.operator.Laplacian` operator.
2347
2348 Parameters
2349 ----------
2350 dim_shape: NDArrayShape
2351 Shape of the input array.
2352 directions: list[NDArray]
2353 List of directions, either constant (array of size :math:`(D,)`) or spatially-varying (array of size
2354 :math:`(D, N_0, \ldots, N_{D-1})`)
2355 weights: list[Real], None
2356 List of optional positive weights with which each second directional derivative operator is multiplied.
2357 diff_method: "gd", "fd"
2358 Method used to approximate the derivative. Must be one of:
2359
2360 * 'fd' (default): finite differences
2361 * 'gd': Gaussian derivative
2362 mode: str, list[str]
2363 Boundary conditions.
2364 Multiple forms are accepted:
2365
2366 * str: unique mode shared amongst dimensions.
2367 Must be one of:
2368
2369 * 'constant' (default): zero-padding
2370 * 'wrap'
2371 * 'reflect'
2372 * 'symmetric'
2373 * 'edge'
2374 * tuple[str, ...]: the `d`-th dimension uses ``mode[d]`` as boundary condition.
2375
2376 (See :py:func:`numpy.pad` for details.)
2377 diff_kwargs: dict
2378 Keyword arguments to parametrize partial derivatives (see
2379 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` and
2380 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative`)
2381
2382 Returns
2383 -------
2384 op: OpT
2385 Directional Laplacian
2386
2387 Example
2388 -------
2389
2390 .. plot::
2391
2392 import numpy as np
2393 import matplotlib.pyplot as plt
2394 from pyxu.operator import DirectionalLaplacian
2395 from pyxu.util.misc import peaks
2396
2397 x = np.linspace(-2.5, 2.5, 25)
2398 xx, yy = np.meshgrid(x, x)
2399 z = peaks(xx, yy)
2400 directions1 = np.zeros(shape=(2, z.size))
2401 directions1[0, :z.size // 2] = 1
2402 directions1[1, z.size // 2:] = 1
2403 directions2 = np.zeros(shape=(2, z.size))
2404 directions2[1, :z.size // 2] = -1
2405 directions2[0, z.size // 2:] = -1
2406 dim_shape = z.shape
2407 dop = DirectionalLaplacian(dim_shape=dim_shape, directions=[directions1, directions2])
2408 out = dop(z)
2409 plt.figure()
2410 h = plt.pcolormesh(xx, yy, z, shading='auto')
2411 plt.quiver(x, x, directions1[1].reshape(dim_shape), directions1[0].reshape(xx.shape))
2412 plt.quiver(x, x, directions2[1].reshape(dim_shape), directions2[0].reshape(xx.shape), color='red')
2413 plt.colorbar(h)
2414 plt.title('Signal and directions of derivatives')
2415 plt.figure()
2416 h = plt.pcolormesh(xx, yy, out.squeeze(), shading='auto')
2417 plt.colorbar(h)
2418 plt.title('Directional Laplacian')
2419
2420 See Also
2421 --------
2422 :py:func:`~pyxu.operator.Laplacian`,
2423 :py:func:`~pyxu.operator.DirectionalDerivative`
2424 """
2425 assert isinstance(directions, cabc.Sequence)
2426
2427 if weights is None:
2428 weights = [1.0] * len(directions)
2429 else:
2430 if len(weights) != len(directions):
2431 raise ValueError("The number of weights and directions provided differ.")
2432
2433 xp = pxu.get_array_module(directions[0])
2434 gpu = xp == pxd.NDArrayInfo.CUPY.module()
2435 dtype = directions[0].dtype
2436 dim_shape = pxu.as_canonical_shape(dim_shape)
2437 hess = Hessian(
2438 dim_shape=dim_shape,
2439 diff_method=diff_method,
2440 mode=mode,
2441 gpu=gpu,
2442 dtype=dtype,
2443 **diff_kwargs,
2444 )
2445
2446 ndim = len(dim_shape)
2447 ndim_diff = ndim * (ndim + 1) // 2
2448 dop = []
2449 dop_compute = []
2450 for i, (weight, direction) in enumerate(zip(weights, directions)):
2451 # normalize directions to unit norm
2452 norm_dirs = (direction / xp.linalg.norm(direction, axis=0, keepdims=True)).astype(dtype)
2453 norm_dirs = norm_dirs[:, None, ...] * norm_dirs[None, ...]
2454 # Multiply off-diag components x2 and use only triangular upper part of the outer product
2455 if ndim > 1:
2456 # (fancy nd indexing not supported in Dask)
2457 norm_dirs = norm_dirs.reshape(ndim**2, *norm_dirs.shape[2:])
2458 dummy_mat = np.arange(ndim**2).reshape(ndim, ndim)
2459 off_diag_inds = dummy_mat[np.triu_indices(ndim, k=1)].ravel()
2460 norm_dirs[off_diag_inds] *= 2
2461 inds = dummy_mat[np.triu_indices(ndim, k=0)].ravel()
2462 norm_dirs = norm_dirs[inds]
2463 else:
2464 norm_dirs = norm_dirs.reshape(-1, *dim_shape)
2465
2466 if direction.ndim == 1:
2467 dop.append(
2468 pxlb.DiagonalOp(weight * xp.tile(norm_dirs, dim_shape + (1,)).transpose().reshape(-1, *dim_shape))
2469 )
2470 dop_compute.append(
2471 pxlb.DiagonalOp(
2472 pxu.compute(weight * xp.tile(norm_dirs, dim_shape + (1,)).transpose().reshape(-1, *dim_shape))
2473 )
2474 )
2475 else:
2476 dop.append(pxlb.DiagonalOp(weight * norm_dirs.reshape(-1, *dim_shape)))
2477 dop_compute.append(pxlb.DiagonalOp(pxu.compute(weight * norm_dirs.reshape(-1, *dim_shape))))
2478
2479 dop = pxb.stack(dop)
2480 dop_compute = pxb.stack(dop_compute)
2481 sop = pxlr.Sum(
2482 dim_shape=(
2483 len(directions),
2484 ndim_diff,
2485 )
2486 + dim_shape,
2487 axis=(0, 1),
2488 )
2489 sqop = pxm.SqueezeAxes(dim_shape=sop.codim_shape, axes=(0, 1))
2490 op = sqop * sop * dop * hess
2491 op_compute = sqop * sop * dop_compute * hess
2492
2493 def op_svdvals(_, **kwargs) -> pxt.NDArray:
2494 return op_compute.svdvals(**kwargs)
2495
2496 setattr(op, "svdvals", types.MethodType(op_svdvals, op))
2497
2498 op._name = "DirectionalLaplacian"
2499 return op
2500
2501
[docs]
2502def DirectionalHessian(
2503 dim_shape: pxt.NDArrayShape,
2504 directions: cabc.Sequence[pxt.NDArray],
2505 diff_method="gd",
2506 mode: ModeSpec = "constant",
2507 **diff_kwargs,
2508) -> pxt.OpT:
2509 r"""
2510 Directional Hessian operator.
2511
2512 Notes
2513 -----
2514
2515 The ``DirectionalHessian`` of a :math:`D`-dimensional signal :math:`\mathbf{f} \in \mathbb{R}^{N_0 \times \cdots
2516 \times N_{D-1}}` stacks the second-order directional derivatives of :math:`\mathbf{f}` along a list of :math:`m`
2517 directions :math:`\mathbf{v}_i` for :math:`1 \leq i \leq m`:
2518
2519 .. math::
2520
2521 \mathbf{H}_{\mathbf{v}_1, \ldots ,\mathbf{v}_m} \mathbf{f} = \begin{bmatrix}
2522 \boldsymbol{\nabla}^2_{\mathbf{v}_0} & \cdots & \boldsymbol{\nabla}_{\mathbf{v}_0} \boldsymbol{\nabla}_{\mathbf{v}_{m-1}} \\
2523 \vdots & \ddots & \vdots \\
2524 \boldsymbol{\nabla}_{\mathbf{v}_{m-1}} \boldsymbol{\nabla}_{\mathbf{v}_0} & \cdots & \boldsymbol{\nabla}^2_{\mathbf{v}_{m-1}}
2525 \end{bmatrix} \mathbf{f},
2526
2527 where :math:`\boldsymbol{\nabla}_{\mathbf{v}_i}` is the first-order directional derivative along
2528 :math:`\mathbf{v}_i` implemented with :py:func:`~pyxu.operator.DirectionalDerivative`.
2529
2530 However, due to the symmetry of the Hessian, only the upper triangular part is computed in practice:
2531
2532 .. math::
2533
2534 \mathbf{H}_{\mathbf{v}_1, \ldots ,\mathbf{v}_m} \mathbf{f} = \begin{bmatrix}
2535 \boldsymbol{\nabla}^2_{\mathbf{v}_0}\\
2536 \boldsymbol{\nabla}_{\mathbf{v}_0} \boldsymbol{\nabla}_{\mathbf{v}_{1}} \\
2537 \vdots \\
2538 \boldsymbol{\nabla}^2_{\mathbf{v}_{m-1}}
2539 \end{bmatrix} \mathbf{f} \in \mathbb{R}^{\frac{m (m-1)}{2} \times N_0 \times \cdots \times N_{D-1}}
2540
2541 Note that choosing :math:`m=D` and :math:`\mathbf{v}_i = \mathbf{e}_i \in \mathbb{R}^D` (the :math:`i`-th canonical
2542 basis vector) amounts to the :py:func:`~pyxu.operator.Hessian` operator.
2543
2544 Parameters
2545 ----------
2546 dim_shape: NDArrayShape
2547 Shape of the input array.
2548 directions: list[NDArray]
2549 List of directions, either constant (array of size :math:`(D,)`) or spatially-varying (array of size :math:`(D,
2550 N_0, \ldots, N_{D-1})`)
2551 diff_method: "gd", "fd"
2552 Method used to approximate the derivative. Must be one of:
2553
2554 * 'fd' (default): finite differences
2555 * 'gd': Gaussian derivative
2556 mode: str, list[str]
2557 Boundary conditions.
2558 Multiple forms are accepted:
2559
2560 * str: unique mode shared amongst dimensions.
2561 Must be one of:
2562
2563 * 'constant' (default): zero-padding
2564 * 'wrap'
2565 * 'reflect'
2566 * 'symmetric'
2567 * 'edge'
2568 * tuple[str, ...]: the `d`-th dimension uses ``mode[d]`` as boundary condition.
2569
2570 (See :py:func:`numpy.pad` for details.)
2571 diff_kwargs: dict
2572 Keyword arguments to parametrize partial derivatives (see
2573 :py:meth:`~pyxu.operator.PartialDerivative.finite_difference` and
2574 :py:meth:`~pyxu.operator.PartialDerivative.gaussian_derivative`)
2575
2576 Returns
2577 -------
2578 op: OpT
2579 Directional Hessian
2580
2581 Example
2582 -------
2583
2584 .. plot::
2585
2586 import numpy as np
2587 import matplotlib.pyplot as plt
2588 from pyxu.operator import DirectionalHessian
2589 from pyxu.util.misc import peaks
2590
2591 x = np.linspace(-2.5, 2.5, 25)
2592 xx, yy = np.meshgrid(x, x)
2593 z = peaks(xx, yy)
2594 directions1 = np.zeros(shape=(2, z.size))
2595 directions1[0, :z.size // 2] = 1
2596 directions1[1, z.size // 2:] = 1
2597 directions2 = np.zeros(shape=(2, z.size))
2598 directions2[1, :z.size // 2] = -1
2599 directions2[0, z.size // 2:] = -1
2600 dim_shape = z.shape
2601 d_hess = DirectionalHessian(dim_shape=dim_shape, directions=[directions1, directions2])
2602 out = d_hess(z)
2603 plt.figure()
2604 h = plt.pcolormesh(xx, yy, z, shading='auto')
2605 plt.quiver(x, x, directions1[1].reshape(dim_shape), directions1[0].reshape(xx.shape))
2606 plt.quiver(x, x, directions2[1].reshape(dim_shape), directions2[0].reshape(xx.shape), color='red')
2607 plt.colorbar(h)
2608 plt.title(r'Signal $\mathbf{f}$ and directions of derivatives')
2609 plt.figure()
2610 h = plt.pcolormesh(xx, yy, out[0], shading='auto')
2611 plt.colorbar(h)
2612 plt.title(r'$\nabla^2_{\mathbf{v}_0} \mathbf{f}$')
2613 plt.figure()
2614 h = plt.pcolormesh(xx, yy, out[1], shading='auto')
2615 plt.colorbar(h)
2616 plt.title(r'$\nabla_{\mathbf{v}_0} \nabla_{\mathbf{v}_{1}} \mathbf{f}$')
2617 plt.figure()
2618 h = plt.pcolormesh(xx, yy, out[2], shading='auto')
2619 plt.colorbar(h)
2620 plt.title(r'$\nabla^2_{\mathbf{v}_1} \mathbf{f}$')
2621
2622 See Also
2623 --------
2624 :py:func:`~pyxu.operator.Hessian`,
2625 :py:func:`~pyxu.operator.DirectionalDerivative`
2626 """
2627
2628 assert isinstance(directions, cabc.Sequence)
2629
2630 xp = pxu.get_array_module(directions[0])
2631 gpu = xp == pxd.NDArrayInfo.CUPY.module()
2632 dtype = directions[0].dtype
2633 dim_shape = pxu.as_canonical_shape(dim_shape)
2634 hess = Hessian(
2635 dim_shape=dim_shape,
2636 diff_method=diff_method,
2637 mode=mode,
2638 gpu=gpu,
2639 dtype=dtype,
2640 **diff_kwargs,
2641 )
2642
2643 ndim = len(dim_shape)
2644 ndim_diff = ndim * (ndim + 1) // 2
2645 dop = []
2646 dop_compute = []
2647 for i1, direction1 in enumerate(directions):
2648 norm_dirs1 = (direction1 / xp.linalg.norm(direction1, axis=0, keepdims=True)).astype(dtype)
2649 for i2, direction2 in enumerate(directions[i1:]):
2650 norm_dirs2 = (direction2 / xp.linalg.norm(direction2, axis=0, keepdims=True)).astype(dtype)
2651 norm_dirs = norm_dirs1[:, None, ...] * norm_dirs2[None, ...]
2652
2653 # Multiply off-diag components x2 and use only triangular upper part of the outer product
2654 if ndim > 1:
2655 # (fancy nd indexing not supported in Dask)
2656 norm_dirs = norm_dirs.reshape(ndim**2, *norm_dirs.shape[2:])
2657 dummy_mat = np.arange(ndim**2).reshape(ndim, ndim)
2658 off_diag_inds = dummy_mat[np.triu_indices(ndim, k=1)].ravel()
2659 norm_dirs[off_diag_inds] *= 2
2660 inds = dummy_mat[np.triu_indices(ndim, k=0)].ravel()
2661 norm_dirs = norm_dirs[inds]
2662 else:
2663 norm_dirs = norm_dirs.reshape(-1, *dim_shape)
2664
2665 if norm_dirs.ndim == 1:
2666 dop.append(pxlb.DiagonalOp(xp.tile(norm_dirs, dim_shape + (1,)).transpose().reshape(-1, *dim_shape)))
2667 dop_compute.append(
2668 pxlb.DiagonalOp(
2669 pxu.compute(xp.tile(norm_dirs, dim_shape + (1,)).transpose().reshape(-1, *dim_shape))
2670 )
2671 )
2672 else:
2673 dop.append(pxlb.DiagonalOp(norm_dirs.reshape(-1, *dim_shape)))
2674 dop_compute.append(pxlb.DiagonalOp(pxu.compute(norm_dirs.reshape(-1, *dim_shape))))
2675
2676 dop = pxb.stack(dop)
2677 dop_compute = pxb.stack(dop_compute)
2678 ndim_hess = len(directions) * (len(directions) + 1) // 2
2679 sop = pxlr.Sum(
2680 dim_shape=(
2681 ndim_hess,
2682 ndim_diff,
2683 )
2684 + dim_shape,
2685 axis=1,
2686 )
2687 sqop = pxm.SqueezeAxes(dim_shape=sop.codim_shape, axes=1)
2688 op = sqop * sop * dop * hess
2689 op_compute = sqop * sop * dop_compute * hess
2690
2691 def op_svdvals(_, **kwargs) -> pxt.NDArray:
2692 return op_compute.svdvals(**kwargs)
2693
2694 setattr(op, "svdvals", types.MethodType(op_svdvals, op))
2695
2696 op._name = "DirectionalHessian"
2697 return op