Coverage for src/lsqfitgp/_Kernel/_ops.py: 99%
174 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/_ops.py
2#
3# Copyright (c) 2020, 2022, 2023, 2025, 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/>.
20"""Register linops on CrossKernel and AffineSpan."""
22import functools 1cba
23import numbers 1cba
24import sys 1cba
26from jax import numpy as jnp 1cba
28from lsqfitgp import _array, _Deriv, _jaxext 1cba
29from lsqfitgp._Kernel import _util 1cba
30from lsqfitgp._Kernel._crosskernel import AffineSpan, CrossKernel 1cba
33def rescale_argparser(fun): 1cba
34 if not callable(fun): 1ba
35 msg = "argument to 'rescale' must be a function" 1a
36 raise ValueError(msg) # noqa: TRY004, changing the type would break callers 1a
37 return fun 1ba
40@functools.partial(CrossKernel.register_corelinop, argparser=rescale_argparser) 1cba
41def rescale(core, xfun, yfun): 1cba
42 r"""
44 Rescale the output of the function.
46 .. math::
47 T(f)(x) = \mathrm{fun}(x) f(x)
49 Parameters
50 ----------
51 xfun, yfun : callable or None
52 Functions from the type of the arguments of the kernel to scalar.
54 """ # noqa: DOC101, DOC103, DOC201
55 if not xfun: 1ba
56 return lambda x, y, **kw: core(x, y, **kw) * yfun(y) 1a
57 elif not yfun: 1ba
58 return lambda x, y, **kw: xfun(x) * core(x, y, **kw) 1ba
59 else:
60 return lambda x, y, **kw: xfun(x) * core(x, y, **kw) * yfun(y) 1ba
63@CrossKernel.register_xtransf 1cba
64def derivable(derivable): 1cba
65 """
66 Specify the degree of derivability of the function.
68 Parameters
69 ----------
70 xderivable, yderivable : int or None
71 Degree of derivability of the function. None means unknown.
73 Notes
74 -----
75 The derivability check is hardcoded into the kernel core and it is not
76 possible to remove it afterwards by applying ``'derivable'`` again with a
77 higher limit.
78 """ # noqa: DOC103, DOC201
79 if isinstance(derivable, bool): 1cba
80 derivable = sys.maxsize if derivable else 0 1cba
81 elif not isinstance(derivable, numbers.Integral) or derivable < 0: 1cba
82 msg = f'derivability degree {derivable!r} not valid' 1a
83 raise ValueError(msg) 1a
85 def error_func(current, n): 1cba
86 msg = ( 1a
87 f'Took {current} derivatives > limit {n} on argument '
88 'of a kernel. This error may be spurious if there are '
89 'derivatives on values that define the input to the kernel, for '
90 'example if a hyperparameter enters the calculation of x. To '
91 'suppress the error, initialize the kernel with derivable=True.'
92 )
93 raise ValueError(msg) 1a
95 def xtransf(x): 1cba
96 if hasattr(x, 'dtype'): 96 ↛ 106line 96 didn't jump to line 106 because the condition on line 96 was always true1cba
97 # this branch handles limit_derivatives not accepting non-jax types
98 # because of being based on jax.custom_jvp; this restriction on
99 # custom_jvp appeared in jax 0.4.17
100 if x.dtype.names is not None: 1cba
101 x = _array.StructuredArray(x) # structured arrays are not 1cba
102 # compatible with jax but common in lsqfitgp, so I wrap them
103 elif not _jaxext.is_jax_type(x.dtype): 1cba
104 return x # since anyway there would be an error if a derivative 1a
105 # tried to pass through a non-jax type
106 return _jaxext.limit_derivatives(x, n=derivable, error_func=error_func) 1cba
108 return xtransf 1cba
111def _asfloat(x): 1cba
112 return x.astype(_jaxext.float_type(x)) 1cba
115def diff_argparser(deriv): 1cba
116 deriv = _Deriv.Deriv(deriv) 1cba
117 return deriv or None 1cba
120@functools.partial(CrossKernel.register_corelinop, argparser=diff_argparser) 1cba
121def diff(core, xderiv, yderiv): # noqa: C901 1cba
122 r"""
124 Derive the function.
126 .. math::
127 T(f)(x) = \frac{\partial^n f}{\partial x^n} (x)
129 Parameters
130 ----------
131 xderiv, yderiv : Deriv_like
132 A `Deriv` or something that can be converted to a `Deriv`.
134 Raises
135 ------
136 RuntimeError
137 The derivative orders are greater than the `derivative` attribute.
139 """ # noqa: DOC101, DOC103, DOC201
140 # reparse derivatives because they could be None
141 xderiv = _Deriv.Deriv(xderiv) 1cba
142 yderiv = _Deriv.Deriv(yderiv) 1cba
144 # wrapper of kernel with derivable arguments unpacked
145 def f(x, y, *args, **kw): 1cba
146 i = -1 1cba
147 if not xderiv.implicit: 1cba
148 for i, dim in enumerate(xderiv): 1cba
149 x = x.at[dim].set(args[i]) 1cba
150 if not yderiv.implicit: 1cba
151 for j, dim in enumerate(yderiv): 1cba
152 y = y.at[dim].set(args[1 + i + j]) 1cba
153 return core(x, y, **kw) 1cba
155 # last x index in case iteration on x does not run but does on y
156 i = -1 1cba
158 # derive w.r.t. first argument
159 if xderiv.implicit: 1cba
160 for _ in range(xderiv.order): 1cba
161 f = _jaxext.elementwise_grad(f, 0) 1cba
162 else:
163 for i, dim in enumerate(xderiv): 1cba
164 for _ in range(xderiv[dim]): 1cba
165 f = _jaxext.elementwise_grad(f, 2 + i) 1cba
167 # derive w.r.t. second argument
168 if yderiv.implicit: 1cba
169 for _ in range(yderiv.order): 1cba
170 f = _jaxext.elementwise_grad(f, 1) 1cba
171 else:
172 for j, dim in enumerate(yderiv): 1cba
173 for _ in range(yderiv[dim]): 1cba
174 f = _jaxext.elementwise_grad(f, 2 + 1 + i + j) 1cba
176 # check derivatives are ok for actual input arrays, wrap structured arrays
177 def process_arg(x, deriv, pos): 1cba
178 if x.dtype.names is not None: 1cba
179 for dim in deriv: 1cba
180 if dim not in x.dtype.names: 1cba
181 msg = f'derivative along missing field {dim!r} on {pos} argument' 1a
182 raise ValueError(msg) 1a
183 if not jnp.issubdtype(x.dtype[dim], jnp.number): 1cba
184 msg = ( 1a
185 f'derivative along non-numeric field {dim!r} on {pos} argument'
186 )
187 raise TypeError(msg) 1a
188 return _array.StructuredArray(x) 1cba
189 elif not deriv.implicit: 1cba
190 msg = ( 1a
191 'derivative on named fields with non-structured '
192 f'array on {pos} argument'
193 )
194 raise ValueError(msg) 1a
195 elif not jnp.issubdtype(x.dtype, jnp.number): 1cba
196 msg = f'derivative along non-numeric array on {pos} argument' 1a
197 raise TypeError(msg) 1a
198 return x 1cba
200 def newcore(x, y, **kw): 1cba
201 x = process_arg(x, xderiv, 'left') 1cba
202 y = process_arg(y, yderiv, 'right') 1cba
204 args = [] 1cba
206 if not xderiv.implicit: 1cba
207 args.extend(_asfloat(x[dim]) for dim in xderiv) 1cba
208 elif xderiv: 1cba
209 x = _asfloat(x) 1cba
211 if not yderiv.implicit: 1cba
212 args.extend(_asfloat(y[dim]) for dim in yderiv) 1cba
213 elif yderiv: 1cba
214 y = _asfloat(y) 1cba
216 return f(x, y, *args, **kw) 1cba
218 return newcore 1cba
221@CrossKernel.register_xtransf 1cba
222def xtransf(fun): 1cba
223 r"""
225 Transform the inputs of the function.
227 .. math::
228 T(f)(x) = f(\mathrm{fun}(x))
230 Parameters
231 ----------
232 xfun, yfun : callable or None
233 Functions mapping a new kind of input to the kind of input accepted by
234 the kernel.
236 """ # noqa: DOC103, DOC201
237 if not callable(fun): 1a
238 msg = "argument to 'xtransf' must be a function" 1a
239 raise ValueError(msg) # noqa: TRY004, changing the type would break callers 1a
240 return fun 1a
243@CrossKernel.register_xtransf 1cba
244def dim(dim): 1cba
245 """
246 Restrict the function to a field of a structured input.
248 That is::
250 T(f)(x) = f(x[dim])
252 If the array is not structured, an exception is raised. If the field for
253 name `dim` has a nontrivial shape, the array passed to the kernel is still
254 structured but has only field `dim`.
256 Parameters
257 ----------
258 xdim, ydim : None, str, list of str
259 Field names or lists of field names.
260 """ # noqa: DOC103, DOC201
261 if not isinstance(dim, (str, list)): 1cba
262 msg = f'dim must be a (list of) string, found {dim!r}' 1a
263 raise TypeError(msg) 1a
265 def fun(x): 1cba
266 if x.dtype.names is None: 1cba
267 msg = f'cannot get dim={dim!r} from non-structured input' 1a
268 raise ValueError(msg) 1a
269 elif x.dtype[dim].shape: 1cba
270 return x[[dim]] 1a
271 else:
272 return x[dim] 1cba
274 return fun 1cba
277@CrossKernel.register_xtransf 1cba
278def maxdim(maxdim): 1cba
279 """
280 Restrict the process to a maximum input dimensionality.
282 Parameters
283 ----------
284 xmaxdim, ymaxdim : None, int
285 Maximum dimensionality of the input.
287 Notes
288 -----
289 Once applied a restriction, the check is hardcoded into the kernel core and
290 it is not possible to remove it by applying again `maxdim` with a larger
291 limit.
292 """ # noqa: DOC103, DOC201
293 if not isinstance(maxdim, numbers.Integral) or maxdim < 0: 1cba
294 msg = f'maximum dimensionality {maxdim!r} not valid' 1a
295 raise ValueError(msg) 1a
297 def fun(x): 1cba
298 nd = _array._nd(x.dtype) 1cba
299 with _jaxext.skipifabstract(): 1cba
300 if nd > maxdim: 1cba
301 msg = f'kernel applied to input with {nd} fields > maxdim={maxdim}' 1a
302 raise ValueError(msg) 1a
303 return x 1cba
305 return fun 1cba
308@CrossKernel.register_xtransf 1cba
309def loc(loc): 1cba
310 r"""
311 Translate the process inputs.
313 .. math::
314 T(f)(x) = f(x - \mathrm{loc})
316 Parameters
317 ----------
318 xloc, yloc: None, number
319 Translations.
321 """ # noqa: DOC103, DOC201
322 with _jaxext.skipifabstract(): 1cba
323 assert -jnp.inf < loc < jnp.inf, loc 1cba
324 return lambda x: _util.ufunc_recurse_dtype(lambda x: x - loc, x) 1cba
327@CrossKernel.register_xtransf 1cba
328def scale(scale): 1cba
329 r"""
330 Rescale the process inputs.
332 .. math::
333 T(f)(x) = f(x / \mathrm{scale})
335 Parameters
336 ----------
337 xscale, yscale: None, number
338 Rescaling factors.
340 """ # noqa: DOC103, DOC201
341 with _jaxext.skipifabstract(): 1cba
342 assert 0 < scale < jnp.inf, scale 1cba
343 return lambda x: _util.ufunc_recurse_dtype(lambda x: x / scale, x) 1cba
346def normalize_argparser(do): 1cba
347 return do or None 1ca
350@functools.partial(CrossKernel.register_corelinop, argparser=normalize_argparser) 1cba
351def normalize(core, dox, doy): 1cba
352 r"""
353 Rescale the process to unit variance.
355 .. math::
356 T(f)(x) &= f(x) / \sqrt{\mathrm{Std}[f(x)]} \\
357 &= f(x) / \sqrt{\mathrm{kernel}(x, x)}
359 Parameters
360 ----------
361 dox, doy : bool
362 Whether to rescale.
363 """ # noqa: DOC101, DOC103, DOC201
364 if dox and doy: 1ca
365 return lambda x, y, **kw: ( 1ca
366 core(x, y, **kw) / jnp.sqrt(core(x, x, **kw) * core(y, y, **kw))
367 )
368 elif dox: 1a
369 return lambda x, y, **kw: core(x, y, **kw) / jnp.sqrt(core(x, x, **kw)) 1a
370 else:
371 return lambda x, y, **kw: core(x, y, **kw) / jnp.sqrt(core(y, y, **kw)) 1a
374@CrossKernel.register_corelinop 1cba
375def cond(core, cond1, cond2, other): 1cba
376 r"""
378 Switch between two independent processes based on a condition.
380 .. math::
381 T(f, g)(x) = \begin{cases}
382 f(x) & \text{if $\mathrm{cond}(x)$,} \\
383 g(x) & \text{otherwise.}
384 \end{cases}
386 Parameters
387 ----------
388 cond1, cond2 : callable
389 Function that is applied on an array of points and must return
390 a boolean array with the same shape.
391 other : CrossKernel
392 Kernel of the process used where the condition is false.
394 """ # noqa: DOC101, DOC103, DOC201
396 def newcore(x, y, **kw): 1ba
397 xcond = cond1(x) 1ba
398 ycond = cond2(y) 1ba
399 r = jnp.where(xcond & ycond, core(x, y, **kw), other(x, y, **kw)) 1ba
400 return jnp.where((xcond ^ ycond).astype('u8'), 0, r) 1ba
401 # the .astype('u8') fixes a weird bug with some old dependencies
403 return newcore 1ba
405 # kernel0.linop('choose', kernel1, kernel2, ..., lambda x: x['index'])
408AffineSpan.inherit_transf('maxdim') 1cba
409AffineSpan.inherit_transf('derivable') 1cba
412@functools.partial(AffineSpan.register_linop, transfname='loc') 1cba
413def affine_loc(tcls, self, xloc, yloc): 1cba
414 dynkw = dict(self.dynkw) 1a
415 newself = tcls.super_transf('loc', self, xloc, yloc) 1a
416 dynkw['lloc'] = dynkw['lloc'] + xloc * dynkw['lscale'] 1a
417 dynkw['rloc'] = dynkw['rloc'] + yloc * dynkw['rscale'] 1a
418 return newself._clone(self.__class__, dynkw=dynkw) 1a
421@functools.partial(AffineSpan.register_linop, transfname='scale') 1cba
422def affine_scale(tcls, self, xscale, yscale): 1cba
423 dynkw = dict(self.dynkw) 1ba
424 newself = tcls.super_transf('scale', self, xscale, yscale) 1ba
425 dynkw['lscale'] = dynkw['lscale'] * xscale 1ba
426 dynkw['rscale'] = dynkw['rscale'] * yscale 1ba
427 return newself._clone(self.__class__, dynkw=dynkw) 1ba