Coverage for src/lsqfitgp/_Kernel/_crosskernel.py: 98%
416 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-04 11:43 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-04 11:43 +0000
1# lsqfitgp/_Kernel/_crosskernel.py
2#
3# Copyright (c) 2020, 2022, 2023, 2026, Giacomo Petrillo
4#
5# This file is part of lsqfitgp.
6#
7# lsqfitgp is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# lsqfitgp is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with lsqfitgp. If not, see <http://www.gnu.org/licenses/>.
20import abc 1cba
21import collections 1cba
22import enum 1cba
23import functools 1cba
24import types 1cba
25import warnings 1cba
27import numpy 1cba
28from jax import numpy as jnp 1cba
30from lsqfitgp import _array, _jaxext, _utils 1cba
31from lsqfitgp._Kernel import _util 1cba
33# subclasses defined in other modules, which set these names when imported
34Kernel: type = None 1cba
35Constant: type = None 1cba
36CrossConstant: type = None 1cba
39@functools.cache 1cba
40def least_common_superclass(*classes): 1cba
41 """
42 Find a "least" common superclass.
44 The class is searched in all the MROs, but the comparison is done with
45 `issubclass` to support virtual inheritance.
46 """
47 mros = [c.__mro__ for c in classes] 1cba
48 indices = [0] * len(mros) 1cba
49 for i, mroi in enumerate(mros): 1cba
50 for j, mroj in enumerate(mros): 1cba
51 if i == j: 1cba
52 continue 1cba
53 while not issubclass(mroi[0], mroj[indices[j]]): 1cba
54 indices[j] += 1 1cba
55 idx = numpy.argmin(indices) 1cba
56 return mros[idx][indices[idx]] 1cba
59class CrossKernel: 1cba
60 r"""
62 Base class to represent kernels, i.e., covariance functions.
64 A kernel is a two-argument function that computes the covariance between
65 two functions at some points according to some probability distribution:
67 .. math::
68 \mathrm{kernel}(x, y) = \mathrm{Cov}[f(x), g(y)].
70 `CrossKernel` objects are callable, the signature is ``obj(x, y)``, and
71 they can be summed and multiplied between them and with scalars. They
72 are immutable; all operations return new objects.
74 Parameters
75 ----------
76 core : callable
77 A function with signature ``core(x, y)``, where ``x`` and ``y``
78 are two broadcastable numpy arrays, which computes the value of the
79 kernel.
80 scale, loc, derivable, maxdim, dim :
81 If specified, these arguments are passed as arguments to the
82 correspondingly named operators, in the order listed here. See `linop`.
83 Briefly: the kernel selects only fields `dim` in the input, checks the
84 dimensionality against `maxdim`, checks there are not too many
85 derivatives taken on the arguments, then transforms as ``(x - loc) /
86 scale``. If any argument is callable, it is passed `**kw` and must
87 return the actual argument. If an argument is a tuple, it is interpreted
88 as a pair of arguments.
89 forcekron : bool, default False
90 If True, apply ``.transf('forcekron')`` to the kernel, before the
91 operations above. Available only for `Kernel`.
92 batchbytes : number, optional
93 If specified, apply ``.batch(batchbytes)`` to the kernel.
94 dynkw : dict, optional
95 Additional keyword arguments passed to `core` that can be modified
96 by transformations. Deleted by transformations by default.
97 **initkw :
98 Additional keyword arguments passed to `core` that can be read but not
99 changed by transformations.
101 Attributes
102 ----------
103 initkw : dict
104 The `initkw` argument.
105 dynkw : dict
106 The `dynkw` argument, or a modification of it if the object has been
107 transformed.
108 core : callable
109 The `core` argument partially evaluated on `initkw`, or another
110 function wrapping it if the object has been transformed.
112 Methods
113 -------
114 batch
115 linop
116 algop
117 transf
118 register_transf
119 register_linop
120 register_corelinop
121 register_xtransf
122 register_algop
123 register_ufuncalgop
124 transf_help
125 has_transf
126 list_transf
127 super_transf
128 make_linop_family
130 See Also
131 --------
132 Kernel
134 Notes
135 -----
136 The predefined class hierarchy and the class logic of the transformations
137 assume that each kernel class corresponds to a subalgebra, i.e., addition
138 and multiplication preserve the class.
140 """
142 __slots__ = '_core', '_dynkw', '_initkw' 1cba
143 # only __new__ and _clone shall access these attributes
145 @property 1cba
146 def initkw(self): 1cba
147 return types.MappingProxyType(self._initkw) 1cba
149 @property 1cba
150 def dynkw(self): 1cba
151 return types.MappingProxyType(self._dynkw) 1cba
153 @property 1cba
154 def core(self): 1cba
155 return self._core 1cba
157 def __new__( 1cba
158 cls,
159 core,
160 *,
161 scale=None,
162 loc=None,
163 derivable=None,
164 maxdim=None,
165 dim=None,
166 forcekron=False,
167 batchbytes=None,
168 dynkw={}, # noqa: B006, read only
169 **initkw,
170 ):
171 self = super().__new__(cls) 1cba
173 self._initkw = initkw 1cba
174 self._dynkw = dict(dynkw) 1cba
175 self._core = lambda x, y, **dynkw: core(x, y, **initkw, **dynkw) 1cba
177 if forcekron: 1cba
178 self = self.transf('forcekron') 1a
180 linop_args = { 1cba
181 'scale': scale,
182 'loc': loc,
183 'derivable': derivable,
184 'maxdim': maxdim,
185 'dim': dim,
186 }
187 for transfname, arg in linop_args.items(): 1cba
188 if callable(arg): 1cba
189 arg = arg(**initkw) 1cba
190 if isinstance(arg, tuple): 1cba
191 self = self.linop(transfname, *arg) 1ba
192 else:
193 self = self.linop(transfname, arg) 1cba
195 if batchbytes is not None: 1cba
196 self = self.batch(batchbytes) 1cba
198 return self 1cba
200 def __call__(self, x, y): 1cba
201 x = _array.asarray(x) 1cba
202 y = _array.asarray(y) 1cba
203 shape = _array.broadcast(x, y).shape 1cba
204 result = self.core(x, y, **self.dynkw) 1cba
205 assert isinstance(result, (numpy.ndarray, jnp.number, jnp.ndarray)) 1cba
206 assert jnp.issubdtype(result.dtype, jnp.number), result.dtype 1cba
207 assert result.shape == shape, (result.shape, shape) 1cba
208 return result 1cba
210 def _clone(self, cls=None, *, initkw=None, dynkw=None, core=None): 1cba
211 newself = object.__new__(self.__class__ if cls is None else cls) 1cba
212 newself._initkw = self._initkw if initkw is None else dict(initkw) 1cba
213 newself._dynkw = {} if dynkw is None else dict(dynkw) 1cba
214 newself._core = self._core if core is None else core 1cba
215 return newself 1cba
217 class _side(enum.Enum): 1cba
218 LEFT = 0 1cba
219 RIGHT = 1 1cba
221 @classmethod 1cba
222 def _nary(cls, op, kernels, side): 1cba
224 if side is cls._side.LEFT: 1cba
225 wrapper = lambda c, _, y, **kw: lambda x: c(x, y, **kw) 1cba
226 arg = lambda x, _: x 1cba
227 elif side is cls._side.RIGHT: 1a
228 wrapper = lambda c, x, _, **kw: lambda y: c(x, y, **kw) 1a
229 arg = lambda _, y: y 1a
230 else: # pragma: no cover
231 raise KeyError(side)
233 cores = [k.core for k in kernels] 1cba
235 def core(x, y, **kw): 1cba
236 wrapped = [wrapper(c, x, y, **kw) for c in cores] 1cba
237 transformed = op(*wrapped) 1cba
238 return transformed(arg(x, y)) 1cba
240 return __class__(core) 1cba
242 def __add__(self, other): 1cba
243 return self.algop('add', other) 1cba
245 __radd__ = __add__ 1cba
247 def __mul__(self, other): 1cba
248 return self.algop('mul', other) 1cba
250 __rmul__ = __mul__ 1cba
252 def __pow__(self, other): 1cba
253 return self.algop('pow', exponent=other) 1a
255 def __rpow__(self, other): 1cba
256 return self.algop('rpow', base=other) 1a
258 def _swap(self): 1cba
259 """Permute the arguments."""
260 core = self.core 1cba
261 return self._clone(__class__, core=lambda x, y, **kw: core(y, x, **kw)) 1cba
263 def batch(self, maxnbytes): 1cba
264 """
265 Return a batched version of the kernel.
267 The batched kernel processes its inputs in chunks to try to limit memory
268 usage.
270 Parameters
271 ----------
272 maxnbytes : number
273 The maximum number of input bytes per chunk, counted after
274 broadcasting the input shapes. Actual broadcasting may not occur if
275 not induced by the operations in the kernel.
277 Returns
278 -------
279 batched_kernel : CrossKernel
280 The same kernel but with batched computations.
281 """
282 core = _jaxext.batchufunc(self.core, maxnbytes=maxnbytes) 1cba
283 return self._clone(core=core) 1cba
285 @classmethod 1cba
286 def _crossmro(cls): 1cba
287 """MRO iterator excluding subclasses of Kernel."""
288 for c in cls.mro(): # pragma: no branch 1cba
289 if not issubclass(c, Kernel): 1cba
290 yield c 1cba
291 if c is __class__: 1cba
292 break 1a
294 _transf = {} # noqa: RUF012, registry shared by design, replaced in subclasses 1cba
296 _Transf = collections.namedtuple('_Transf', ['func', 'doc', 'kind']) 1cba
298 def __init_subclass__(cls, **kw): 1cba
299 super().__init_subclass__(**kw) 1cba
300 cls._transf = {} 1cba
301 cls.__slots__ = () 1cba
303 @classmethod 1cba
304 def _transfmro(cls): 1cba
305 """Iterate over the superclasses with a _transf attribute."""
306 for c in cls.mro(): # pragma: no branch 1cba
307 yield c 1cba
308 if c is __class__: 1cba
309 break 1cba
311 @classmethod 1cba
312 def _settransf(cls, transfname, transf): 1cba
313 if transfname in cls._transf: 1cba
314 msg = f'transformation {transfname!r} already registered for {cls.__name__}' 1a
315 raise KeyError(msg) 1a
316 cls._transf[transfname] = cls._Transf(*transf) 1cba
318 @classmethod 1cba
319 def _alltransf(cls): 1cba
320 """List all accessible transfs as dict name -> (tcls, transf)."""
321 transfs = {} 1cba
322 for tcls in cls._transfmro(): 1cba
323 for name, transf in tcls._transf.items(): 1cba
324 transfs.setdefault(name, (tcls, transf)) 1cba
325 return transfs 1cba
327 @classmethod 1cba
328 def _gettransf(cls, transfname, transfmro=None): 1cba
329 """
330 Find a transformation.
332 The transformation is searched following the MRO up to `CrossKernel`.
334 Parameters
335 ----------
336 transfname : hashable
337 The transformation name.
338 transfmro : iterable of types, optional
339 The classes to search, by default the MRO up to `CrossKernel`.
341 Returns
342 -------
343 cls : type
344 The class where the transformation was found.
345 transf, doc, kind : tuple
346 The objects set by `register_transf`.
348 Raises
349 ------
350 KeyError :
351 The transformation was not found.
352 """
353 if transfmro is None: 1cba
354 transfmro = cls._transfmro() 1cba
355 for c in transfmro: 1cba
356 if transfname in c._transf: 1cba
357 return c, c._transf[transfname] 1cba
358 raise KeyError(transfname) 1a
360 @classmethod 1cba
361 def inherit_transf(cls, transfname, *, intermediates=False): 1cba
362 """
363 Inherit a transformation from a superclass.
365 Parameters
366 ----------
367 transfname : hashable
368 The name of the transformation.
369 intermediates : bool, default False
370 If True, make all superclasses up to the one definining the
371 transformation inherit it too.
373 Raises
374 ------
375 KeyError :
376 The transformation was not found in any superclass, or the
377 transformation is already registered on any of the target classes.
379 See Also
380 --------
381 transf
382 """
383 tcls, transf = cls._gettransf(transfname) 1cba
384 cls._settransf(transfname, transf) 1cba
385 if intermediates: 1cba
386 for c in cls.mro()[1:]: # pragma: no branch 1cba
387 if c is tcls: 1cba
388 break 1cba
389 c._settransf(transfname, transf) 1cba
391 @classmethod 1cba
392 def inherit_all_algops(cls, intermediates=False): 1cba
393 """
394 Inherit all algebraic operations from superclasses.
396 This makes sense if the class represents a subalgebra, i.e., it
397 should be preserved by addition and multiplication.
399 Parameters
400 ----------
401 intermediates : bool, default False
402 If True, make all superclasses up to the one definining the
403 transformation inherit it too.
405 Raises
406 ------
407 KeyError :
408 An algebraic operation is already registered for one of the target
409 classes.
411 See Also
412 --------
413 transf
414 """
415 mro = cls._transfmro() 1cba
416 next(mro) 1cba
417 for name, (_, transf) in next(mro)._alltransf().items(): 1cba
418 if transf.kind is cls._algopmarker: 1cba
419 cls.inherit_transf(name, intermediates=intermediates) 1cba
421 @classmethod 1cba
422 def list_transf(cls, superclasses=True): 1cba
423 """
424 List all the available transformations.
426 Parameters
427 ----------
428 superclasses : bool, default True
429 Include transformations defined in superclasses.
431 Returns
432 -------
433 transfs : dict of Transf
434 The dictionary keys are the transformation names, the values are
435 named tuples ``(tcls, kind, impl, doc)`` where ``tcls`` is the class
436 defining the transformation, ``kind`` is the kind of transformation,
437 ``impl`` is the implementation with signature ``impl(tcls, self,
438 *args, **kw)``, ``doc`` is the docstring.
439 """
440 if superclasses: 1ca
441 source = cls._alltransf().items 1a
442 else:
444 def source(): 1ca
445 for name, transf in cls._transf.items(): 1ca
446 yield name, (cls, transf) 1ca
448 return { 1ca
449 name: cls.Transf(tcls, transf.kind, transf.func, transf.doc)
450 for name, (tcls, transf) in source()
451 }
453 Transf = collections.namedtuple('Transf', ['tcls', 'kind', 'func', 'doc']) 1cba
455 @classmethod 1cba
456 def has_transf(cls, transfname): 1cba
457 """
458 Check if a transformation is registered.
460 Parameters
461 ----------
462 transfname : hashable
463 The transformation name.
465 Returns
466 -------
467 has_transf : bool
468 Whether the transformation is registered.
470 See Also
471 --------
472 transf
473 """
474 try: 1a
475 cls._gettransf(transfname) 1a
476 except KeyError as exc: 1a
477 if exc.args == (transfname,): 1a
478 return False 1a
479 else: # pragma: no cover
480 raise
481 else:
482 return True 1a
484 @classmethod 1cba
485 def transf_help(cls, transfname): 1cba
486 """
487 Return the documentation of a transformation.
489 Parameters
490 ----------
491 transfname : hashable
492 The name of the transformation.
494 Returns
495 -------
496 doc : str
497 The documentation of the transformation.
499 See Also
500 --------
501 transf
502 """
503 _, transf = cls._gettransf(transfname) 1a
504 return transf.doc 1a
506 def transf(self, transfname, *args, **kw): 1cba
507 """
508 Return a transformed kernel.
510 Parameters
511 ----------
512 transfname : hashable
513 A name identifying the transformation.
514 *args :
515 Positional arguments to the transformation.
516 **kw :
517 Keyword arguments to the transformation.
519 Returns
520 -------
521 newkernel : object
522 The output of the transformation.
524 Raises
525 ------
526 KeyError
527 The transformation is not defined in this class or any superclass.
529 See Also
530 --------
531 linop, algop, transf_help, has_transf, list_transf, super_transf, register_transf, register_linop, register_corelinop, register_xtransf, register_algop, register_ufuncalgop
532 """
533 tcls, transf = self._gettransf(transfname) 1a
534 return transf.func(tcls, self, *args, **kw) 1a
536 @classmethod 1cba
537 def super_transf(cls, transfname, self, *args, **kw): 1cba
538 """
539 Transform the kernel using a superclass transformation.
541 This is equivalent to `transf` but is invoked on a class and the
542 object is passed explicitly. The definition of the transformation is
543 searched starting from the next class in the MRO of `self`.
545 Parameters
546 ----------
547 transfname : hashable
548 See `transf`.
549 self : CrossKernel
550 The object to transform.
551 *args :
552 See `transf`.
553 **kw :
554 See `transf`.
556 Returns
557 -------
558 newkernel : object
559 The output of the transformation.
560 """
561 mro = list(self._transfmro()) 1ba
562 idx = mro.index(cls) 1ba
563 tcls, transf = self._gettransf(transfname, mro[idx + 1 :]) 1ba
564 return transf.func(tcls, self, *args, **kw) 1ba
566 def linop(self, transfname, *args, **kw): 1cba
567 r"""
569 Transform kernels to represent the application of a linear operator.
571 .. math::
572 \text{kernel}_1(x, y) &= \mathrm{Cov}[f_1(x), g_1(y)], \\
573 \text{kernel}_2(x, y) &= \mathrm{Cov}[f_2(x), g_2(y)], \\
574 &\ldots \\
575 \text{newkernel}(x, y) &=
576 \mathrm{Cov}[T_f(f_1, f_2, \ldots)(x), T_g(g_1, g_2, \ldots)(y)]
578 Parameters
579 ----------
580 transfname : hashable
581 The name of the transformation.
582 *args :
583 A sequence of `CrossKernel` instances, representing the operands,
584 followed by one or two non-kernel arguments, indicating how to act
585 on each side of the kernels. If both arguments represent the
586 identity, this is a no-op. If there is only one argument, it is
587 intended that the two arguments are equal. `None` always represents
588 the identity.
589 **kw :
590 Ignored.
592 Returns
593 -------
594 newkernel : CrossKernel
595 The transformed kernel.
597 Raises
598 ------
599 ValueError :
600 The transformation exists but was not defined by `register_linop`.
602 See Also
603 --------
604 transf
606 Notes
607 -----
608 The linear operator is defined on the function the kernel represents the
609 distribution of, not in full generality on the kernel itself. When
610 multiple kernels are involved, their distributions may be considered
611 independent or not, depending on the specific operation.
613 If the result is a subclass of the class defining the transformation,
614 the result is casted to the latter. Then, if the result and all the
615 operands are instances of `Kernel`, but the two operator arguments
616 differ, the result is casted to its first non-`Kernel` superclass.
618 """
619 tcls, transf = self._gettransf(transfname) 1cba
620 if transf.kind is not self._linopmarker: 1cba
621 msg = ( 1a
622 f'the transformation {transfname!r} was not '
623 f'defined with register_linop and so can not be invoked '
624 f'by linop'
625 )
626 raise ValueError(msg) 1a
627 return transf.func(tcls, self, *args) 1cba
629 def algop(self, transfname, *operands, **kw): 1cba
630 r"""
632 Return a nonnegative algebraic transformation of the input kernels.
634 .. math::
635 \mathrm{newkernel}(x, y) &=
636 f(\mathrm{kernel}_1(x, y), \mathrm{kernel}_2(x, y), \ldots), \\
637 f(z_1, z_2, \ldots) &= \sum_{k_1,k_2,\ldots=0}^\infty
638 a_{k_1 k_2 \ldots} z_1^{k_1} z_2^{k_2} \ldots,
639 \quad a_* \ge 0.
641 Parameters
642 ----------
643 transfname : hashable
644 A name identifying the transformation.
645 *operands : CrossKernel, scalar
646 Arguments to the transformation in addition to self.
647 **kw :
648 Additional arguments to the transformation, not considered as
649 operands.
651 Returns
652 -------
653 newkernel : CrossKernel or NotImplemented
654 The transformed kernel, or NotImplemented if the operation is
655 not supported.
657 See Also
658 --------
659 transf
661 Notes
662 -----
663 The class of `newkernel` is the least common superclass of: the
664 "natural" output of the operation, the class defining the
665 transformation, and the classes of the operands.
667 For class determination, scalars in the input count as `Constant`
668 if nonnegative or traced by jax, else `CrossConstant`.
670 """
671 tcls, transf = self._gettransf(transfname) 1cba
672 if transf.kind is not self._algopmarker: 1cba
673 msg = ( 1a
674 f'the transformation {transfname!r} was not '
675 f'defined with register_algop and so can not be invoked '
676 f'by algop'
677 )
678 raise ValueError(msg) 1a
679 return transf.func(tcls, self, *operands, **kw) 1cba
681 @classmethod 1cba
682 def register_transf(cls, func, transfname=None, doc=None, kind=None): 1cba
683 """
684 Register a transformation for use with `transf`.
686 The transformation will be accessible to subclasses.
688 Parameters
689 ----------
690 func : callable
691 A function ``func(tcls, self, *args, **kw) -> object`` implementing
692 the transformation, where ``tcls`` is the class that defines the
693 transformation.
694 transfname : hashable, optional.
695 The `transfname` parameter to `transf` this transformation will
696 be accessible under. If not specified, use the name of `func`.
697 doc : str, optional
698 The documentation of the transformation for `transf_help`. If not
699 specified, use the docstring of `func`.
700 kind : object, optional
701 An arbitrary marker.
703 Returns
704 -------
705 func : callable
706 The `func` argument as is.
708 Raises
709 ------
710 KeyError :
711 The name is already in use for another transformation in the same
712 class.
714 See Also
715 --------
716 transf
717 """
718 if transfname is None: 1cba
719 transfname = func.__name__ 1cba
720 if doc is None: 1cba
721 doc = func.__doc__ 1cba
722 cls._settransf(transfname, (func, doc, kind)) 1cba
723 return func 1cba
725 @classmethod 1cba
726 def register_linop(cls, op, transfname=None, doc=None, argparser=None): # noqa: C901 1cba
727 """
728 Register a transformation for use with `linop`.
730 Parameters
731 ----------
732 op : callable
733 A function ``op(tcls, self, arg1, arg2, *operands) -> CrossKernel``
734 that returns the new kernel, where ``arg1`` and ``arg2`` represent
735 the operators acting on each side of the kernels, and ``operands``
736 are the other kernels beyond ``self``.
737 transfname : hashable, optional
738 See `register_transf`.
739 doc : str, optional
740 See `register_transf`.
741 argparser : callable, optional
742 A function applied to ``arg1`` and ``arg2``. Not called if the
743 argument is `None`. It should map the identity to `None`.
745 Returns
746 -------
747 op : callable
748 The `op` argument as is.
750 See Also
751 --------
752 transf
754 Notes
755 -----
756 The function `op` is called only if ``arg1`` or ``arg2`` is not `None`
757 after potential conversion with `argparser`.
758 """
759 if transfname is None: 1cba
760 transfname = op.__name__ # for result type error message 1cba
762 @functools.wraps(op) 1cba
763 def func(tcls, self, *allargs): # noqa: C901 1cba
765 # split the arguments in kernels and non-kernels
766 for pos, arg in enumerate(allargs): # noqa: B007, `pos` used after the loop 1cba
767 if not isinstance(arg, __class__): # ty: ignore[unresolved-reference] 1cba
768 break 1cba
769 else:
770 pos = len(allargs) 1a
771 operands = allargs[:pos] 1cba
772 args = allargs[pos:] 1cba
774 # check the arguments from the first non-kernel onwards are 1 or 2
775 if len(args) not in (1, 2): 1cba
776 msg = ( 1a
777 f'incorrect number of non-kernel tail '
778 f'arguments {len(args)}, expected 1 or 2'
779 )
780 raise ValueError(msg) 1a
782 # wrap argument parser to enforce preserving None
783 if argparser: 1cba
784 conv = lambda x: None if x is None else argparser(x) 1cba
785 else:
786 conv = lambda x: x 1cba
788 # determine if the two arguments count as "identical" or not
789 if len(args) == 1: 1cba
790 arg = conv(*args) 1cba
791 different = False 1cba
792 arg1 = arg2 = arg 1cba
793 else:
794 arg1, arg2 = args 1cba
795 different = arg1 is not arg2 1cba
796 arg1 = conv(arg1) 1cba
797 arg2 = conv(arg2) 1cba
798 different &= arg1 is not arg2 1cba
799 # they must be not identical both before and after to handle
800 # these cases:
801 # - if the user passes identical arguments, but argparser
802 # makes copies, it must still count as identical
803 # - if the user passes arguments which are not identical,
804 # but argparser sends them to the same object, then they
805 # surely represent the same transf
807 # handle no-op case
808 if arg1 is None and arg2 is None: 1cba
809 return self 1cba
811 # invoke implementation
812 result = op(tcls, self, arg1, arg2, *operands) 1cba
814 # check result is a kernel
815 if not isinstance(result, __class__): # ty: ignore[unresolved-reference] 1cba
816 msg = ( 1a
817 f'linop {transfname!r} returned '
818 f'object of type {result.__class__.__name__}, expected '
819 f'subclass of {__class__.__name__}' # ty: ignore[unresolved-reference]
820 )
821 raise TypeError(msg) 1a
823 # modify class of the result
824 rcls = result.__class__ 1cba
825 if issubclass(rcls, tcls): 1cba
826 rcls = tcls 1cba
827 all_operands_kernel = all(isinstance(o, Kernel) for o in operands) 1cba
828 if isinstance(self, Kernel) and all_operands_kernel and different: 1cba
829 rcls = next(rcls._crossmro()) 1cba
830 if rcls is not result.__class__: 1cba
831 result = result._clone(rcls) 1cba
833 return result 1cba
835 cls.register_transf(func, transfname, doc, cls._linopmarker) 1cba
836 return op 1cba
838 class _LinOpMarker(str): 1cba
839 pass 1cba
841 _linopmarker = _LinOpMarker('linop') 1cba
843 @classmethod 1cba
844 def register_corelinop(cls, corefunc, transfname=None, doc=None, argparser=None): 1cba
845 """
846 Register a linear operator with a function that acts only on the core.
848 Parameters
849 ----------
850 corefunc : callable
851 A function ``corefunc(core, arg1, arg2, *cores) -> newcore``, where
852 ``core`` is the function that implements the kernel passed at
853 initialization, and ``cores`` for other operands.
854 transfname : hashable, optional
855 See `register_linop`.
856 doc : str, optional
857 See `register_linop`.
858 argparser : callable, optional
859 See `register_linop`.
861 Returns
862 -------
863 corefunc : callable
864 The `corefunc` argument as is.
866 See Also
867 --------
868 transf
869 """
871 @functools.wraps(corefunc) 1cba
872 def op(_, self, arg1, arg2, *operands): 1cba
873 cores = (o.core for o in operands) 1cba
874 core = corefunc(self.core, arg1, arg2, *cores) 1cba
875 return self._clone(core=core) 1cba
877 cls.register_linop(op, transfname, doc, argparser) 1cba
878 return corefunc 1cba
880 @classmethod 1cba
881 def register_xtransf(cls, xfunc, transfname=None, doc=None): 1cba
882 """
883 Register a linear operator that acts only on the input.
885 Parameters
886 ----------
887 xfunc : callable
888 A function ``xfunc(arg) -> (lambda x: newx)`` that takes in a
889 `linop` argument and produces a function to transform the input. Not
890 called if ``arg`` is `None`. To indicate the identity, return
891 `None`. There is no `argparser` parameter (see `register_linop`)
892 because its functionality can be included in `xfunc`.
893 transfname : hashable, optional
894 See `register_linop`.
895 doc : str, optional
896 See `register_linop`.
898 Returns
899 -------
900 xfunc : callable
901 The `xfunc` argument as is.
903 See Also
904 --------
905 transf
906 """
908 @functools.wraps(xfunc) 1cba
909 def corefunc(core, xfun, yfun): 1cba
910 if not xfun: 1cba
911 return lambda x, y, **kw: core(x, yfun(y), **kw) 1a
912 elif not yfun: 1cba
913 return lambda x, y, **kw: core(xfun(x), y, **kw) 1a
914 else:
915 return lambda x, y, **kw: core(xfun(x), yfun(y), **kw) 1cba
917 cls.register_corelinop(corefunc, transfname, doc, xfunc) 1cba
918 return xfunc 1cba
920 @classmethod 1cba
921 def register_algop(cls, op, transfname=None, doc=None): 1cba
922 """
923 Register a transformation for use with `algop`.
925 Parameters
926 ----------
927 op : callable
928 A function ``op(tcls, *kernels, **kw) -> CrossKernel |
929 NotImplemented`` that returns the new kernel. ``kernels`` may be
930 scalars but for the first argument.
931 transfname : hashable, optional
932 See `register_transf`.
933 doc : str, optional
934 See `register_transf`.
936 Returns
937 -------
938 op : callable
939 The `op` argument as is.
941 See Also
942 --------
943 transf
944 """
945 if transfname is None: 1cba
946 transfname = op.__name__ # for error message 1cba
948 @functools.wraps(op) 1cba
949 def func(tcls, *operands, **kw): 1cba
950 result = op(tcls, *operands, **kw) 1cba
952 if result is NotImplemented: 1cba
953 return result 1a
954 elif not isinstance(result, __class__): # ty: ignore[unresolved-reference] 1cba
955 msg = ( 1a
956 f'algop {transfname!r} returned '
957 f'object of type {result.__class__.__name__}, expected '
958 f'subclass of {__class__.__name__}' # ty: ignore[unresolved-reference]
959 )
960 raise TypeError(msg) 1a
962 def classes(): 1cba
963 yield tcls 1cba
964 for o in operands: 1cba
965 if isinstance(o, __class__): # ty: ignore[unresolved-reference] 1cba
966 yield o.__class__ 1cba
967 elif _util.is_nonnegative_scalar_trueontracer(o): 1cba
968 yield Constant 1cba
969 elif _util.is_numerical_scalar(o): 1a
970 yield CrossConstant 1a
971 else:
972 msg = ( 1a
973 f'operands to algop {transfname!r} '
974 f'must be CrossKernel or numbers, found {o!r}'
975 )
976 raise TypeError(msg) 1a
977 # this type check comes after letting the implementation
978 # return NotImplemented, to support overloading
979 yield result.__class__ 1cba
981 lcs = least_common_superclass(*classes()) 1cba
982 return result._clone(lcs) 1cba
984 cls.register_transf(func, transfname, doc, cls._algopmarker) 1cba
985 return op 1cba
987 class _AlgOpMarker(str): 1cba
988 pass 1cba
990 _algopmarker = _AlgOpMarker('algop') 1cba
992 @classmethod 1cba
993 def register_ufuncalgop(cls, ufunc, transfname=None, doc=None): 1cba
994 """
995 Register an algebraic operation that acts only on the kernel value.
997 Parameters
998 ----------
999 ufunc : callable
1000 A function ``ufunc(*values, **kw) -> value``, where ``values`` are
1001 the values yielded by the operands.
1002 transfname : hashable, optional
1003 See `register_transf`.
1004 doc : str, optional
1005 See `register_transf`.
1007 Returns
1008 -------
1009 ufunc : callable
1010 The `ufunc` argument as is.
1012 See Also
1013 --------
1014 transf
1015 """
1017 @functools.wraps(ufunc) 1cba
1018 def op(_, self, *operands, **kw): 1cba
1019 def constcore(value): 1ca
1020 return lambda _x, _y, **_: value 1a
1022 cores = tuple( 1ca
1023 o.core if isinstance(o, __class__) else constcore(o) # ty: ignore[unresolved-reference]
1024 for o in (self, *operands)
1025 )
1027 def core(x, y, **kw): 1ca
1028 values = (core(x, y, **kw) for core in cores) 1a
1029 return ufunc(*values, **kw) 1a
1031 return self._clone(core=core) 1ca
1033 cls.register_algop(op, transfname, doc) 1cba
1034 return ufunc 1cba
1036 @classmethod 1cba
1037 def make_linop_family( # noqa: C901, PLR0915 1cba
1038 cls,
1039 transfname,
1040 bothker,
1041 leftker,
1042 rightker=None,
1043 *,
1044 doc=None,
1045 argparser=None,
1046 argnames=None,
1047 translkw=None,
1048 ):
1049 """
1050 Form a family of kernels classes related by linear operators.
1052 The class this method is called on is the seed class. A new
1053 trasformation is registered to obtain other classes from the seed class
1054 by applying a newly defined linop transformation.
1056 Parameters
1057 ----------
1058 transfname : str
1059 The name of the new transformation.
1060 bothker : CrossKernel
1061 The kernel class to be obtained by applying the operator to a seed
1062 class object on both sides. All classes (`bothker`, `leftker`,
1063 `rightker`) are assumed to require no positional arguments at
1064 construction, and recognize the same set of keyword arguments.
1065 leftker : CrossKernel
1066 Like `bothker`, but applying the operator only on the left.
1067 rightker : CrossKernel, optional
1068 Like `bothker`, but applying the operator only on the right. If not
1069 specified, it is defined by subclassing `leftker` and transposing
1070 the kernel on object construction.
1071 doc : str, optional
1072 See `register_linop`.
1073 argparser : callable, optional
1074 See `register_linop`.
1075 argnames : pair of str, optional
1076 If specified, `leftker` is passed an additional keyword argument
1077 with name ``argnames[0]`` that specifies the argument of the
1078 operator, `rightker` is passed ``argnames[1]``, and `bothker`
1079 similarly is passed both.
1081 If `leftker` is used to implement `rightker`, it must accept both
1082 arguments, and may use them to determine if it is being invoked as
1083 `leftker` or `rightker`.
1084 translkw : callable, optional
1085 A function with signature ``translkw(dynkw, <argnames>, **initkw) ->
1086 dict`` that determines the constructor arguments for a new object
1087 starting from the ones of the object to be transformed. By default,
1088 ``initkw`` is passed over, and an error is raised if ``dynkw`` is
1089 not empty.
1091 See Also
1092 --------
1093 transf
1095 Examples
1096 --------
1097 >>> @lgp.kernel
1098 ... def A(x, y, *, gatto):
1099 ... ''' The reknown A kernel of order gatto '''
1100 ... return ...
1101 ...
1102 >>> @lgp.kernel
1103 ... def T(n, m, *, gatto, op):
1104 ... ''' The kernel of the Topo series of an A process of order
1105 ... gatto '''
1106 ... op1, op2 = op
1107 ... return ...
1108 ...
1109 >>> @lgp.crosskernel
1110 ... def CrossTA(n, x, *, gatto, op):
1111 ... ''' The cross covariance between the Topo series of an A
1112 ... process of order gatto and the process itself '''
1113 ... return ...
1114 ...
1115 >>> A.make_linop_family('topo', T, CrossTA)
1116 >>> a = A(gatto=7)
1117 >>> ta = A.linop('topo', True, None)
1118 >>> at = A.linop('topo', None, True)
1119 >>> t = A.linop('topo', True)
1120 """
1121 if rightker is None: 1cba
1122 # invent a name for rightker
1123 rightname = f'Cross{cls.__name__}{bothker.__name__}' 1cba
1125 # define how to set up rightker
1126 def exec_body(ns): 1cba
1128 if leftker.__doc__: 1cba
1129 header = 'Automatically generated transposed version of:\n\n' 1a
1130 ns['__doc__'] = _utils.append_to_docstring( 1a
1131 leftker.__doc__, header, front=True
1132 )
1134 def __new__(cls, *args, **kw): 1cba
1135 self = super(rightker, cls).__new__(cls, *args, **kw) # ty: ignore[invalid-super-argument] 1a
1137 if self.__class__ is cls: 1a
1138 self = self._swap() 1a
1139 if not isinstance(self, leftker): 1139 ↛ 1140line 1139 didn't jump to line 1140 because the condition on line 1139 was never true1a
1140 msg = (
1141 f'newly created instance of '
1142 f'automatically defined {rightker.__name__} is not an '
1143 f'instance of {leftker.__name__} after '
1144 f'transposition. Either define transposition '
1145 f'for {leftker.__name__}, or define '
1146 f'{rightker.__name__} manually'
1147 )
1148 raise TypeError(msg)
1149 return self._clone(cls) 1a
1151 else:
1152 return self._swap() 1a
1154 ns['__new__'] = __new__ 1cba
1156 # create rightker, evil twin of leftker separated at birth
1157 rightker = types.new_class(rightname, (leftker,), exec_body=exec_body) 1cba
1159 # check which classes are symmetric
1160 classes = cls, bothker, leftker, rightker 1cba
1161 sym = tuple(issubclass(c, Kernel) for c in classes) 1cba
1162 exp = True, True, False, False 1cba
1163 if sym != exp: 1cba
1164 desc = lambda t: 'Kernel' if t else 'non-Kernel' 1a
1165 warnings.warn( 1a
1166 f'Expected classes pattern {", ".join(map(desc, exp))}, '
1167 f'found {", ".join(map(desc, sym))}'
1168 )
1170 # set translkw if not specified
1171 if translkw is None: 1cba
1173 def translkw(*, dynkw, **initkw): 1a
1174 if dynkw: 1174 ↛ 1175line 1174 didn't jump to line 1175 because the condition on line 1174 was never true1a
1175 msg = (
1176 'found non-empty `dynkw`, the default '
1177 'implementation of `translkw` does not support it'
1178 )
1179 raise ValueError(msg)
1180 return initkw 1a
1182 # function to produce the arguments to the transformed objects
1183 def makekw(self, arg1, arg2): 1cba
1184 kw = dict(dynkw=self.dynkw, **self.initkw) 1ba
1185 if argnames is not None: 1ba
1186 if arg1 is not None: 1a
1187 kw = dict(**kw, **{argnames[0]: arg1}) 1a
1188 if arg2 is not None: 1a
1189 kw = dict(**kw, **{argnames[1]: arg2}) 1a
1190 return translkw(**kw) 1ba
1192 # register linop mapping cls to either leftker, rightker or bothker
1193 regkw = dict(transfname=transfname, doc=doc, argparser=argparser) 1cba
1195 @functools.partial(cls.register_linop, **regkw) 1cba
1196 def op_seed_to_siblings(_, self, arg1, arg2): 1cba
1197 kw = makekw(self, arg1, arg2) 1ba
1198 if arg2 is None: 1ba
1199 return leftker(**kw) 1ba
1200 elif arg1 is None: 1ba
1201 return rightker(**kw) 1a
1202 else:
1203 return bothker(**kw) 1ba
1205 # register linop mapping leftker to bothker
1206 @functools.partial(leftker.register_linop, **regkw) 1cba
1207 def op_left_to_both(_, self, arg1, arg2): 1cba
1208 if arg1 is None: 1a
1209 return bothker(**makekw(self, arg1, arg2)) 1a
1210 else:
1211 msg = ( 1a
1212 f'cannot further transform '
1213 f'`{leftker.__name__}` on left side with linop '
1214 f'{transfname!r}'
1215 )
1216 raise ValueError(msg) 1a
1218 # register linop mapping rightker to bothker
1219 @functools.partial(rightker.register_linop, **regkw) 1cba
1220 def op_right_to_both(_, self, arg1, arg2): 1cba
1221 if arg2 is None: 1a
1222 return bothker(**makekw(self, arg1, arg2)) 1a
1223 else:
1224 msg = ( 1a
1225 f'cannot further transform '
1226 f'`{rightker.__name__}` on right side with linop '
1227 f'{transfname!r}'
1228 )
1229 raise ValueError(msg) 1a
1232class AffineSpan(CrossKernel, abc.ABC): 1cba
1233 """
1234 Kernel that tracks affine transformations.
1236 An `AffineSpan` instance accumulates the overall affine transformation
1237 applied to its inputs and output.
1239 `AffineSpan` and it subclasses are preserved by the transformations
1240 'scale', 'loc', 'add' (with scalar) and 'mul' (with scalar).
1242 `AffineSpan` can not be instantiated directly or used as standalone
1243 superclass. It must be the first base before concrete superclasses.
1244 """
1246 _affine_dynkw = dict(lloc=0, rloc=0, lscale=1, rscale=1, offset=0, ampl=1) # noqa: RUF012, read-only 1cba
1248 def __new__(cls, *args, dynkw={}, **kw): # noqa: B006, read only 1cba
1249 if cls is __class__: 1cba
1250 msg = f'cannot instantiate {__class__.__name__} directly' 1a
1251 raise TypeError(msg) 1a
1252 new_dynkw = dict(cls._affine_dynkw) 1cba
1253 new_dynkw.update(dynkw) 1cba
1254 return super().__new__(cls, *args, dynkw=new_dynkw, **kw) 1cba
1256 def __init_subclass__(cls, **kw): 1cba
1257 super().__init_subclass__(**kw) 1cba
1258 for name in __class__._transf: 1cba
1259 cls.inherit_transf(name) 1cba
1261 # core(x, y) method, which __init_subclass__ converts to staticmethod,
1262 # and have __new__ use it to set _core (I can't use the same name with
1263 # slots).
1264 #
1265 # to use without post-class registering make_linopfamily, make it a
1266 # decorator with attributed subdecorators for other members:
1267 #
1268 # @linop_family('T')
1269 # @kernel
1270 # def A(...)
1271 # @A.left('arg')
1272 # @crosskernel
1273 # def CrossTA(...)
1274 # @A.right # optional, generated from left if not specified
1275 # ...
1276 # @A.both('arg1', 'arg2')
1277 # @kernel
1278 # def T(...)
1279 #
1280 # Can also be stacked on top of left/right/both to chain families. Works
1281 # both with decorators and explicit class definitions.
1283 def _clone(self, *args, **kw): 1cba
1284 newself = super()._clone(*args, **kw) 1cba
1285 if isinstance(newself, __class__): 1cba
1286 for name in self._affine_dynkw: 1cba
1287 newself._dynkw[name] = self._dynkw[name] 1cba
1288 return newself 1cba
1290 @classmethod 1cba
1291 def __subclasshook__(cls, sub): 1cba
1292 if cls is __class__: 1cba
1293 return NotImplemented 1cba
1294 # to avoid algops promoting to unqualified AffineSpan
1295 if issubclass(cls, Kernel): 1cba
1296 if issubclass(sub, Constant): 1cba
1297 return True 1a
1298 else:
1299 return NotImplemented 1cba
1300 elif issubclass(sub, CrossConstant): 1a
1301 return True 1a
1302 else:
1303 return NotImplemented 1a
1306class PreservedBySwap(CrossKernel): 1cba
1307 def __new__(cls, *args, **kw): 1cba
1308 if cls is __class__: 1308 ↛ 1309line 1308 didn't jump to line 1309 because the condition on line 1308 was never true1ba
1309 msg = f'cannot instantiate {__class__.__name__} directly'
1310 raise TypeError(msg)
1311 return super().__new__(cls, *args, **kw) 1ba
1313 def _swap(self): 1cba
1314 return super()._swap()._clone(self.__class__) 1ba