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

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/>. 

19 

20import abc 1cba

21import collections 1cba

22import enum 1cba

23import functools 1cba

24import types 1cba

25import warnings 1cba

26 

27import numpy 1cba

28from jax import numpy as jnp 1cba

29 

30from lsqfitgp import _array, _jaxext, _utils 1cba

31from lsqfitgp._Kernel import _util 1cba

32 

33# subclasses defined in other modules, which set these names when imported 

34Kernel: type = None 1cba

35Constant: type = None 1cba

36CrossConstant: type = None 1cba

37 

38 

39@functools.cache 1cba

40def least_common_superclass(*classes): 1cba

41 """ 

42 Find a "least" common superclass. 

43 

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

57 

58 

59class CrossKernel: 1cba

60 r""" 

61 

62 Base class to represent kernels, i.e., covariance functions. 

63 

64 A kernel is a two-argument function that computes the covariance between 

65 two functions at some points according to some probability distribution: 

66 

67 .. math:: 

68 \mathrm{kernel}(x, y) = \mathrm{Cov}[f(x), g(y)]. 

69 

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. 

73 

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. 

100 

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. 

111 

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 

129 

130 See Also 

131 -------- 

132 Kernel 

133 

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. 

139 

140 """ 

141 

142 __slots__ = '_core', '_dynkw', '_initkw' 1cba

143 # only __new__ and _clone shall access these attributes 

144 

145 @property 1cba

146 def initkw(self): 1cba

147 return types.MappingProxyType(self._initkw) 1cba

148 

149 @property 1cba

150 def dynkw(self): 1cba

151 return types.MappingProxyType(self._dynkw) 1cba

152 

153 @property 1cba

154 def core(self): 1cba

155 return self._core 1cba

156 

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

172 

173 self._initkw = initkw 1cba

174 self._dynkw = dict(dynkw) 1cba

175 self._core = lambda x, y, **dynkw: core(x, y, **initkw, **dynkw) 1cba

176 

177 if forcekron: 1cba

178 self = self.transf('forcekron') 1a

179 

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

194 

195 if batchbytes is not None: 1cba

196 self = self.batch(batchbytes) 1cba

197 

198 return self 1cba

199 

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

209 

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

216 

217 class _side(enum.Enum): 1cba

218 LEFT = 0 1cba

219 RIGHT = 1 1cba

220 

221 @classmethod 1cba

222 def _nary(cls, op, kernels, side): 1cba

223 

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) 

232 

233 cores = [k.core for k in kernels] 1cba

234 

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

239 

240 return __class__(core) 1cba

241 

242 def __add__(self, other): 1cba

243 return self.algop('add', other) 1cba

244 

245 __radd__ = __add__ 1cba

246 

247 def __mul__(self, other): 1cba

248 return self.algop('mul', other) 1cba

249 

250 __rmul__ = __mul__ 1cba

251 

252 def __pow__(self, other): 1cba

253 return self.algop('pow', exponent=other) 1a

254 

255 def __rpow__(self, other): 1cba

256 return self.algop('rpow', base=other) 1a

257 

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

262 

263 def batch(self, maxnbytes): 1cba

264 """ 

265 Return a batched version of the kernel. 

266 

267 The batched kernel processes its inputs in chunks to try to limit memory 

268 usage. 

269 

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. 

276 

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

284 

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

293 

294 _transf = {} # noqa: RUF012, registry shared by design, replaced in subclasses 1cba

295 

296 _Transf = collections.namedtuple('_Transf', ['func', 'doc', 'kind']) 1cba

297 

298 def __init_subclass__(cls, **kw): 1cba

299 super().__init_subclass__(**kw) 1cba

300 cls._transf = {} 1cba

301 cls.__slots__ = () 1cba

302 

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

310 

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

317 

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

326 

327 @classmethod 1cba

328 def _gettransf(cls, transfname, transfmro=None): 1cba

329 """ 

330 Find a transformation. 

331 

332 The transformation is searched following the MRO up to `CrossKernel`. 

333 

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`. 

340 

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`. 

347 

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

359 

360 @classmethod 1cba

361 def inherit_transf(cls, transfname, *, intermediates=False): 1cba

362 """ 

363 Inherit a transformation from a superclass. 

364 

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. 

372 

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. 

378 

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

390 

391 @classmethod 1cba

392 def inherit_all_algops(cls, intermediates=False): 1cba

393 """ 

394 Inherit all algebraic operations from superclasses. 

395 

396 This makes sense if the class represents a subalgebra, i.e., it 

397 should be preserved by addition and multiplication. 

398 

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. 

404 

405 Raises 

406 ------ 

407 KeyError : 

408 An algebraic operation is already registered for one of the target 

409 classes. 

410 

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

420 

421 @classmethod 1cba

422 def list_transf(cls, superclasses=True): 1cba

423 """ 

424 List all the available transformations. 

425 

426 Parameters 

427 ---------- 

428 superclasses : bool, default True 

429 Include transformations defined in superclasses. 

430 

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: 

443 

444 def source(): 1ca

445 for name, transf in cls._transf.items(): 1ca

446 yield name, (cls, transf) 1ca

447 

448 return { 1ca

449 name: cls.Transf(tcls, transf.kind, transf.func, transf.doc) 

450 for name, (tcls, transf) in source() 

451 } 

452 

453 Transf = collections.namedtuple('Transf', ['tcls', 'kind', 'func', 'doc']) 1cba

454 

455 @classmethod 1cba

456 def has_transf(cls, transfname): 1cba

457 """ 

458 Check if a transformation is registered. 

459 

460 Parameters 

461 ---------- 

462 transfname : hashable 

463 The transformation name. 

464 

465 Returns 

466 ------- 

467 has_transf : bool 

468 Whether the transformation is registered. 

469 

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

483 

484 @classmethod 1cba

485 def transf_help(cls, transfname): 1cba

486 """ 

487 Return the documentation of a transformation. 

488 

489 Parameters 

490 ---------- 

491 transfname : hashable 

492 The name of the transformation. 

493 

494 Returns 

495 ------- 

496 doc : str 

497 The documentation of the transformation. 

498 

499 See Also 

500 -------- 

501 transf 

502 """ 

503 _, transf = cls._gettransf(transfname) 1a

504 return transf.doc 1a

505 

506 def transf(self, transfname, *args, **kw): 1cba

507 """ 

508 Return a transformed kernel. 

509 

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. 

518 

519 Returns 

520 ------- 

521 newkernel : object 

522 The output of the transformation. 

523 

524 Raises 

525 ------ 

526 KeyError 

527 The transformation is not defined in this class or any superclass. 

528 

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

535 

536 @classmethod 1cba

537 def super_transf(cls, transfname, self, *args, **kw): 1cba

538 """ 

539 Transform the kernel using a superclass transformation. 

540 

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`. 

544 

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`. 

555 

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

565 

566 def linop(self, transfname, *args, **kw): 1cba

567 r""" 

568 

569 Transform kernels to represent the application of a linear operator. 

570 

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)] 

577 

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. 

591 

592 Returns 

593 ------- 

594 newkernel : CrossKernel 

595 The transformed kernel. 

596 

597 Raises 

598 ------ 

599 ValueError : 

600 The transformation exists but was not defined by `register_linop`. 

601 

602 See Also 

603 -------- 

604 transf 

605 

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. 

612 

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. 

617 

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

628 

629 def algop(self, transfname, *operands, **kw): 1cba

630 r""" 

631 

632 Return a nonnegative algebraic transformation of the input kernels. 

633 

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. 

640 

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. 

650 

651 Returns 

652 ------- 

653 newkernel : CrossKernel or NotImplemented 

654 The transformed kernel, or NotImplemented if the operation is 

655 not supported. 

656 

657 See Also 

658 -------- 

659 transf 

660 

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. 

666 

667 For class determination, scalars in the input count as `Constant` 

668 if nonnegative or traced by jax, else `CrossConstant`. 

669 

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

680 

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`. 

685 

686 The transformation will be accessible to subclasses. 

687 

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. 

702 

703 Returns 

704 ------- 

705 func : callable 

706 The `func` argument as is. 

707 

708 Raises 

709 ------ 

710 KeyError : 

711 The name is already in use for another transformation in the same 

712 class. 

713 

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

724 

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`. 

729 

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`. 

744 

745 Returns 

746 ------- 

747 op : callable 

748 The `op` argument as is. 

749 

750 See Also 

751 -------- 

752 transf 

753 

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

761 

762 @functools.wraps(op) 1cba

763 def func(tcls, self, *allargs): # noqa: C901 1cba

764 

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

773 

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

781 

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

787 

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 

806 

807 # handle no-op case 

808 if arg1 is None and arg2 is None: 1cba

809 return self 1cba

810 

811 # invoke implementation 

812 result = op(tcls, self, arg1, arg2, *operands) 1cba

813 

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

822 

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

832 

833 return result 1cba

834 

835 cls.register_transf(func, transfname, doc, cls._linopmarker) 1cba

836 return op 1cba

837 

838 class _LinOpMarker(str): 1cba

839 pass 1cba

840 

841 _linopmarker = _LinOpMarker('linop') 1cba

842 

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. 

847 

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`. 

860 

861 Returns 

862 ------- 

863 corefunc : callable 

864 The `corefunc` argument as is. 

865 

866 See Also 

867 -------- 

868 transf 

869 """ 

870 

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

876 

877 cls.register_linop(op, transfname, doc, argparser) 1cba

878 return corefunc 1cba

879 

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. 

884 

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`. 

897 

898 Returns 

899 ------- 

900 xfunc : callable 

901 The `xfunc` argument as is. 

902 

903 See Also 

904 -------- 

905 transf 

906 """ 

907 

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

916 

917 cls.register_corelinop(corefunc, transfname, doc, xfunc) 1cba

918 return xfunc 1cba

919 

920 @classmethod 1cba

921 def register_algop(cls, op, transfname=None, doc=None): 1cba

922 """ 

923 Register a transformation for use with `algop`. 

924 

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`. 

935 

936 Returns 

937 ------- 

938 op : callable 

939 The `op` argument as is. 

940 

941 See Also 

942 -------- 

943 transf 

944 """ 

945 if transfname is None: 1cba

946 transfname = op.__name__ # for error message 1cba

947 

948 @functools.wraps(op) 1cba

949 def func(tcls, *operands, **kw): 1cba

950 result = op(tcls, *operands, **kw) 1cba

951 

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

961 

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

980 

981 lcs = least_common_superclass(*classes()) 1cba

982 return result._clone(lcs) 1cba

983 

984 cls.register_transf(func, transfname, doc, cls._algopmarker) 1cba

985 return op 1cba

986 

987 class _AlgOpMarker(str): 1cba

988 pass 1cba

989 

990 _algopmarker = _AlgOpMarker('algop') 1cba

991 

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. 

996 

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`. 

1006 

1007 Returns 

1008 ------- 

1009 ufunc : callable 

1010 The `ufunc` argument as is. 

1011 

1012 See Also 

1013 -------- 

1014 transf 

1015 """ 

1016 

1017 @functools.wraps(ufunc) 1cba

1018 def op(_, self, *operands, **kw): 1cba

1019 def constcore(value): 1ca

1020 return lambda _x, _y, **_: value 1a

1021 

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 ) 

1026 

1027 def core(x, y, **kw): 1ca

1028 values = (core(x, y, **kw) for core in cores) 1a

1029 return ufunc(*values, **kw) 1a

1030 

1031 return self._clone(core=core) 1ca

1032 

1033 cls.register_algop(op, transfname, doc) 1cba

1034 return ufunc 1cba

1035 

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. 

1051 

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. 

1055 

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. 

1080 

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. 

1090 

1091 See Also 

1092 -------- 

1093 transf 

1094 

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

1124 

1125 # define how to set up rightker 

1126 def exec_body(ns): 1cba

1127 

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 ) 

1133 

1134 def __new__(cls, *args, **kw): 1cba

1135 self = super(rightker, cls).__new__(cls, *args, **kw) # ty: ignore[invalid-super-argument] 1a

1136 

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

1150 

1151 else: 

1152 return self._swap() 1a

1153 

1154 ns['__new__'] = __new__ 1cba

1155 

1156 # create rightker, evil twin of leftker separated at birth 

1157 rightker = types.new_class(rightname, (leftker,), exec_body=exec_body) 1cba

1158 

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 ) 

1169 

1170 # set translkw if not specified 

1171 if translkw is None: 1cba

1172 

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

1181 

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

1191 

1192 # register linop mapping cls to either leftker, rightker or bothker 

1193 regkw = dict(transfname=transfname, doc=doc, argparser=argparser) 1cba

1194 

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

1204 

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

1217 

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

1230 

1231 

1232class AffineSpan(CrossKernel, abc.ABC): 1cba

1233 """ 

1234 Kernel that tracks affine transformations. 

1235 

1236 An `AffineSpan` instance accumulates the overall affine transformation 

1237 applied to its inputs and output. 

1238 

1239 `AffineSpan` and it subclasses are preserved by the transformations 

1240 'scale', 'loc', 'add' (with scalar) and 'mul' (with scalar). 

1241 

1242 `AffineSpan` can not be instantiated directly or used as standalone 

1243 superclass. It must be the first base before concrete superclasses. 

1244 """ 

1245 

1246 _affine_dynkw = dict(lloc=0, rloc=0, lscale=1, rscale=1, offset=0, ampl=1) # noqa: RUF012, read-only 1cba

1247 

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

1255 

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

1260 

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. 

1282 

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

1289 

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

1304 

1305 

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

1312 

1313 def _swap(self): 1cba

1314 return super()._swap()._clone(self.__class__) 1ba