Coverage for src/lsqfitgp/_fit.py: 84%
554 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/_fit.py
2#
3# Copyright (c) 2020, 2022, 2023, 2024, 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/>.
20import datetime 1cba
21import functools 1cba
22import re 1cba
23import textwrap 1cba
24import time 1cba
25import warnings 1cba
27import gvar 1cba
28import jax 1cba
29import jax.core 1cba
30import numpy 1cba
31from jax import jit as jax_jit # `jit` is a parameter of empbayes_fit 1cba
32from jax import numpy as jnp 1cba
33from jax import tree 1cba
34from scipy import optimize 1cba
36from lsqfitgp import _GP, _array, _gvarext, _linalg 1cba
39@functools.singledispatch 1cba
40def token_getter(x): 1cba
41 return x
44@functools.singledispatch 1cba
45def token_setter(x, token): # noqa: ARG001, the dispatch argument 1cba
46 return token
49@token_getter.register(jax.core.Tracer) 1cba
50@token_getter.register(jnp.ndarray) 1cba
51@token_getter.register(numpy.ndarray) 1cba
52def _(x): 1cba
53 return x[x.ndim * (0,)] if x.size else x 1cba
56@token_setter.register(jax.core.Tracer) 1cba
57@token_setter.register(jnp.ndarray) 1cba
58@token_setter.register(numpy.ndarray) 1cba
59def _(x, token): 1cba
60 x = jnp.asarray(x) 1cba
61 return x.at[x.ndim * (0,)].set(token) if x.size else token 1cba
64def token_map_leaf(func, x): 1cba
65 if isinstance(x, (jnp.ndarray, numpy.ndarray)): 65 ↛ 79line 65 didn't jump to line 79 because the condition on line 65 was always true1cba
66 token = token_getter(x) 1cba
68 @jax.custom_jvp 1cba
69 def jaxfunc(token): 1cba
70 return jax.pure_callback(func, token, token, vmap_method='expand_dims') 1cba
72 @jaxfunc.defjvp 1cba
73 def _(p, t): 1cba
74 return (jaxfunc(*p), *t) 1cba
76 token = jaxfunc(token) 1cba
77 return token_setter(x, token) 1cba
78 else:
79 token = token_getter(x)
80 token = func(token)
81 return token_setter(x, token)
84def token_map(func, x): 1cba
85 return tree.map(lambda x: token_map_leaf(func, x), x) 1cba
88class Logger: 1cba
89 """
90 Class to manage a log.
92 Can be used as superclass. Each line of the log has a verbosity level (an
93 integer >= 0) and is printed only if this level is below a threshold. All
94 lines are saved and the log can be retrieved.
95 """
97 def __init__(self, target_verbosity=0): 1cba
98 """Set the threshold used to exclude log lines."""
99 self._verbosity = target_verbosity 1cba
100 self._loggedlines = [] 1cba
102 def _indent(self, text, level=0): 1cba
103 """Indent a text by provided level or by global current level."""
104 level = max(0, level + self.loglevel._level) 1ba
105 prefix = 4 * level * ' ' 1ba
106 return textwrap.indent(text, prefix) 1ba
108 def _select(self, verbosity, target_verbosity=None): 1cba
109 if target_verbosity is None: 109 ↛ 111line 109 didn't jump to line 111 because the condition on line 109 was always true1cba
110 target_verbosity = self._verbosity 1cba
111 if isinstance(verbosity, int): 1cba
112 return target_verbosity >= verbosity 1cba
113 else:
114 return target_verbosity in verbosity 1cba
116 def log(self, message, verbosity=1, *, level=0): 1cba
117 """
118 Print and record a message.
120 Parameters
121 ----------
122 message : str
123 The message to print. A newline is added unconditionally.
124 verbosity : int or set, default 1
125 The verbosity level(s) at which the message is printed. If an
126 integer, it's printed at all levels >= that integer. If a set, at
127 the specified levels.
128 level : int, default 0
129 The indentation level of the message.
130 """
131 if self._select(verbosity): 1cba
132 print(self._indent(message, level)) # noqa: T201, the fit log 1ba
133 self._loggedlines.append((message, verbosity, level + self.loglevel._level)) 1cba
135 def getlog(self, target_verbosity=None, *, base_level=0): 1cba
136 """Return all logged line as a single string."""
137 return '\n'.join(
138 self._indent(message, base_level + level)
139 for message, verbosity, level in self._loggedlines
140 if self._select(verbosity, target_verbosity)
141 )
143 class _LogLevel: 1cba
144 """Shared context manager to indent messages."""
146 _level = 0 1cba
148 @classmethod 1cba
149 def __enter__(cls): 1cba
150 cls._level += 1 1cba
152 @classmethod 1cba
153 def __exit__(cls, *_): 1cba
154 cls._level -= 1 1cba
156 loglevel = _LogLevel() 1cba
159class empbayes_fit(Logger): 1cba
160 SEPARATE_JAC = False 1cba
162 def __init__( 1cba
163 self,
164 hyperprior,
165 gpfactory,
166 data,
167 *,
168 raises=True,
169 minkw={}, # noqa: B006, read only
170 gpfactorykw={}, # noqa: B006, read only
171 jit=True,
172 method='gradient',
173 initial='priormean',
174 verbosity=0,
175 covariance='auto',
176 fix=None,
177 mlkw={}, # noqa: B006, read only
178 forward=False,
179 additional_loss=None,
180 ):
181 """
182 Maximum a posteriori fit.
184 Maximizes the marginal likelihood of the data with a Gaussian process
185 model that depends on hyperparameters, multiplied by a prior on the
186 hyperparameters.
188 Parameters
189 ----------
190 hyperprior : scalar, array or dictionary of scalars/arrays
191 A collection of gvars representing the prior for the
192 hyperparameters.
193 gpfactory : callable
194 A function with signature gpfactory(hyperparams) -> GP object. The
195 argument ``hyperparams`` has the same structure of the
196 `empbayes_fit` argument ``hyperprior``. gpfactory must be
197 JAX-friendly, i.e., use `jax.numpy` and `jax.scipy` instead of plain
198 `numpy`/`scipy` and avoid assignments to arrays.
199 data : dict, tuple or callable
200 Dictionary of data that is passed to `GP.marginal_likelihood` on
201 the GP object returned by ``gpfactory``. If a tuple, it contains the
202 first two arguments to `GP.marginal_likelihood`. If a callable, it
203 is called with the same arguments of ``gpfactory`` and must return
204 the argument(s) for `GP.marginal_likelihood`.
205 raises : bool, optional
206 If True (default), raise an error when the minimization fails.
207 Otherwise, use the last point of the minimization as result.
208 minkw : dict, optional
209 Keyword arguments passed to `scipy.optimize.minimize`, overwrites
210 values specified by `empbayes_fit`.
211 gpfactorykw : dict, optional
212 Keyword arguments passed to ``gpfactory``, and also to ``data`` if
213 it is a callable. If ``jit``, ``gpfactorykw`` crosses a `jax.jit`
214 boundary, so it must contain objects understandable by `jax`.
215 jit : bool
216 If True (default), use `jax.jit` to compile the minimization target.
217 method : str
218 Minimization strategy. Options:
220 'nograd'
221 Use a gradient-free method.
222 'gradient' (default)
223 Use a gradient-only method.
224 'fisher'
225 Use a Newton method with the Fisher information matrix plus
226 the hyperprior precision matrix.
227 initial : str, scalar, array, dictionary of scalars/arrays
228 Starting point for the minimization, matching the format of
229 ``hyperprior``, or one of the following options:
231 'priormean' (default)
232 Start from the hyperprior mean.
233 'priorsample'
234 Take a random sample from the hyperprior.
235 verbosity : int
236 An integer indicating how much information is printed on the
237 terminal:
239 0 (default)
240 No logging.
241 1
242 Minimal report.
243 2
244 Detailed report.
245 3
246 Log each iteration.
247 4
248 More detailed iteration log.
249 5
250 Print the current parameter values at each iteration.
251 covariance : str
252 Method to estimate the posterior covariance matrix of the
253 hyperparameters:
255 'fisher'
256 Use the Fisher information in the MAP, plus the prior precision,
257 as precision matrix.
258 'minhess'
259 Use the hessian estimate of the minimizer as precision matrix.
260 'none'
261 Do not estimate the covariance matrix.
262 'auto' (default)
263 ``'minhess'`` if applicable, ``'none'`` otherwise.
264 fix : scalar, array or dictionary of scalars/arrays
265 A set of booleans, with the same format as ``hyperprior``,
266 indicating which hyperparameters are kept fixed to their initial
267 value. Scalars and arrays are broadcasted to the shape of
268 ``hyperprior``. If a dictionary, missing keys are treated as False.
269 mlkw : dict
270 Additional arguments passed to `GP.marginal_likelihood`.
271 forward : bool, default False
272 Use forward instead of backward derivatives. Typically, forward is
273 faster with a small number of parameters.
274 additional_loss : callable, optional
275 A function with signature ``additional_loss(hyperparams) -> float``
276 which is added to the minus log marginal posterior of the
277 hyperparameters.
279 Attributes
280 ----------
281 p : scalar, array or dictionary of scalars/arrays
282 A collection of gvars representing the hyperparameters that
283 maximize their posterior. These gvars do not track correlations
284 with the hyperprior or the data.
285 prior : scalar, array or dictionary of scalars/arrays
286 A copy of the hyperprior.
287 initial : scalar, array or dictionary of scalars/arrays
288 Starting point of the minimization, with the same format as ``p``.
289 fix : scalar, array or dictionary of scalars/arrays
290 A set of booleans, with the same format as ``p``, indicating which
291 parameters were kept fixed to the values in ``initial``.
292 pmean : scalar, array or dictionary of scalars/arrays
293 Mean of ``p``.
294 pcov : scalar, array or dictionary of scalars/arrays
295 Covariance matrix of ``p``.
296 minresult : scipy.optimize.OptimizeResult
297 The result object returned by `scipy.optimize.minimize`.
298 minargs : dict
299 The arguments passed to `scipy.optimize.minimize`.
300 gpfactory : callable
301 The ``gpfactory`` argument.
302 gpfactorykw : dict
303 The ``gpfactorykw`` argument.
304 data : dict, tuple or callable
305 The ``data`` argument.
307 Raises
308 ------
309 RuntimeError
310 The minimization failed and ``raises`` is True.
311 """
312 Logger.__init__(self, verbosity) 1cba
313 del verbosity 1cba
314 self.log('**** call lsqfitgp.empbayes_fit ****') 1cba
316 assert callable(gpfactory) 1cba
318 # analyze the hyperprior
319 hpinitial, hpunflat = self._parse_hyperprior(hyperprior, initial, fix) 1cba
320 del hyperprior, initial, fix 1cba
322 # analyze data
323 data, cachedargs = self._parse_data(data) 1cba
325 # define functions
326 timer, functions = self._prepare_functions( 1cba
327 gpfactory=gpfactory,
328 gpfactorykw=gpfactorykw,
329 data=data,
330 cachedargs=cachedargs,
331 hpunflat=hpunflat,
332 mlkw=mlkw,
333 jit=jit,
334 forward=forward,
335 additional_loss=additional_loss,
336 )
337 del gpfactory, gpfactorykw, data, cachedargs, mlkw, forward, additional_loss 1cba
339 # prepare minimizer arguments
340 minargs = self._prepare_minargs(method, functions, hpinitial) 1cba
342 # set up callback to time and log iterations
343 callback = self._Callback(self, functions, timer, hpunflat) 1cba
344 minargs.update(callback=callback) 1cba
346 # check invalid argument before running minimizer
347 if not covariance in ('auto', 'fisher', 'minhess', 'none'): 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true1cba
348 raise KeyError(covariance)
350 # add user arguments and minimize
351 minargs.update(minkw) 1cba
352 self.log(f'minimizer method {minargs["method"]!r}', 2) 1cba
353 total = time.perf_counter() 1cba
354 result = optimize.minimize(**minargs) 1cba
356 # check the minimization was successful
357 self._check_success(result, raises) 1cba
359 # compute posterior covariance of the hyperparameters
360 cov = self._posterior_covariance( 1cba
361 method, covariance, result, functions['fisher']
362 )
364 # log total timings and function calls
365 total = time.perf_counter() - total 1cba
366 self._log_totals(total, timer, callback, jit, functions) 1cba
368 ##### temporary fix for gplepage/gvar#50 #####
369 cov = numpy.array(cov, order='C') 1cba
370 ##############################################
372 # join posterior mean and covariance matrix
373 uresult = gvar.gvar(result.x, cov) 1cba
375 # set attributes
376 self.p = gvar.gvar(hpunflat(uresult)) 1cba
377 self.pmean = gvar.mean(self.p) 1cba
378 self.pcov = gvar.evalcov(self.p) 1cba
379 self.minresult = result 1cba
380 self.minargs = minargs 1cba
382 # tabulate hyperparameter prior and posterior
383 if self._verbosity >= 2: 1cba
384 self.log( 1ba
385 _gvarext.tabulate_together(
386 self.prior, self.p, headers=['param', 'prior', 'posterior']
387 )
388 )
390 self.log('**** exit lsqfitgp.empbayes_fit ****') 1cba
392 class _CountCalls: 1cba
393 """Wrap a callable to count calls."""
395 def __init__(self, func): 1cba
396 self._func = func 1cba
397 self._total = 0 1cba
398 self._partial = 0 1cba
399 functools.update_wrapper(self, func) 1cba
401 def __call__(self, *args, **kw): 1cba
402 self._total += 1 1cba
403 self._partial += 1 1cba
404 return self._func(*args, **kw) 1cba
406 def partial(self): 1cba
407 """Return the partial counter and reset it."""
408 result = self._partial 1cba
409 self._partial = 0 1cba
410 return result 1cba
412 def total(self): 1cba
413 """Return the total number of calls."""
414 return self._total 1cba
416 @staticmethod 1cba
417 def fmtcalls(method, functions): 1cba
418 """
419 Format summary of number of calls.
421 method : str
422 functions: dict[str, _CountCalls].
423 """
425 def counts(): 1cba
426 for name, func in functions.items(): 1cba
427 if count := getattr(func, method)(): 1cba
428 yield f'{name} {count}' 1cba
430 return ', '.join(counts()) 1cba
432 class _Timer: 1cba
433 """Object to time likelihood computations."""
435 def __init__(self): 1cba
436 self.totals = {} 1cba
437 self.partials = {} 1cba
438 self._last_start = False 1cba
440 def start(self, token): 1cba
441 return token_map(self._start, token) 1cba
443 def _start(self, token): 1cba
444 self.stamp = time.perf_counter() 1cba
445 self.counter = 0 1cba
446 assert not self._last_start # forbid consecutive start() calls 1cba
447 self._last_start = True 1cba
448 return token 1cba
450 def reset(self): 1cba
451 self.partials = {} 1cba
453 def partial(self, token): 1cba
454 return token_map(self._partial, token) 1cba
456 def _partial(self, token): 1cba
457 now = time.perf_counter() 1cba
458 delta = now - self.stamp 1cba
459 self.partials[self.counter] = self.partials.get(self.counter, 0) + delta 1cba
460 self.totals[self.counter] = self.totals.get(self.counter, 0) + delta 1cba
461 self.stamp = now 1cba
462 self.counter += 1 1cba
463 self._last_start = False 1cba
464 return token 1cba
466 def _parse_hyperprior(self, hyperprior, initial, fix): 1cba
468 # check fix against hyperprior and fill missing values
469 hyperprior = self._copyasarrayorbufferdict(hyperprior) 1cba
470 self._check_no_redundant_keys(hyperprior) 1cba
471 fix = self._parse_fix(hyperprior, fix) 1cba
472 flatfix = self._flatview(fix) 1cba
474 # extract distribution of free hyperparameters
475 flathp = self._flatview(hyperprior) 1cba
476 freehp = flathp[~flatfix] 1cba
477 mean = gvar.mean(freehp) 1cba
478 cov = gvar.evalcov(freehp) 1cba
479 dec = _linalg.Chol(cov) 1cba
480 assert dec.n == freehp.size 1cba
481 self.log(f'{freehp.size}/{flathp.size} free hyperparameters', 2) 1cba
483 # determine starting point for minimization
484 initial = self._parse_initial(hyperprior, initial, dec) 1cba
485 flatinitial = self._flatview(initial) 1cba
486 x0 = dec.pinv_correlate(flatinitial[~flatfix] - mean) 1cba
488 # make function to correlate, add fixed values, and reshape to original
489 # format
490 (fixed_indices,) = jnp.nonzero(flatfix) 1cba
491 (unfixed_indices,) = jnp.nonzero(~flatfix) 1cba
492 fixed_values = jnp.asarray(flatinitial[flatfix]) 1cba
494 def unflat(x): 1cba
495 assert x.ndim == 1 1cba
496 if x.dtype == object: 1cba
497 jac, indices = _gvarext.jacobian(x) 1cba
498 xmean = mean + dec.correlate(gvar.mean(x)) 1cba
499 xjac = dec.correlate(jac) 1cba
500 x = _gvarext.from_jacobian(xmean, xjac, indices) 1cba
501 y = numpy.empty(flatfix.size, x.dtype) 1cba
502 numpy.put(y, unfixed_indices, x) 1cba
503 numpy.put(y, fixed_indices, fixed_values) 1cba
504 else:
505 x = mean + dec.correlate(x) 1cba
506 y = jnp.empty(flatfix.size, x.dtype) 1cba
507 y = y.at[unfixed_indices].set(x) 1cba
508 y = y.at[fixed_indices].set(fixed_values) 1cba
509 return self._unflatview(y, hyperprior) 1cba
511 self.prior = hyperprior 1cba
512 return x0, unflat 1cba
514 @staticmethod 1cba
515 def _check_no_redundant_keys(hyperprior): 1cba
516 if not hasattr(hyperprior, 'keys'): 1cba
517 return 1a
518 for k in hyperprior: 1cba
519 m = hyperprior.extension_pattern.match(k) 1cba
520 if m and m.group(1) in hyperprior.invfcn: 1cba
521 altk = m.group(2) 1cba
522 if altk in hyperprior: 522 ↛ 523line 522 didn't jump to line 523 because the condition on line 522 was never true1cba
523 msg = f'duplicate keys {altk!r} and {k!r} in hyperprior'
524 raise ValueError(msg)
526 def _parse_fix(self, hyperprior, fix): 1cba
528 if fix is None: 528 ↛ 536line 528 didn't jump to line 536 because the condition on line 528 was always true1cba
529 if hasattr(hyperprior, 'keys'): 1cba
530 fix = gvar.BufferDict( 1cba
531 hyperprior, buf=numpy.zeros(hyperprior.size, bool)
532 )
533 else:
534 fix = numpy.zeros(hyperprior.shape, bool) 1a
535 else:
536 fix = self._copyasarrayorbufferdict(fix)
537 if hasattr(fix, 'keys'):
538 assert hasattr(hyperprior, 'keys'), (
539 'fix is dictionary but hyperprior is array'
540 )
541 assert all(hyperprior.has_dictkey(k) for k in fix), (
542 'some keys in fix are missing in hyperprior'
543 )
544 newfix = {}
545 for k, v in hyperprior.items():
546 key = None
547 m = hyperprior.extension_pattern.match(k)
548 if m and m.group(1) in hyperprior.invfcn:
549 altk = m.group(2)
550 if altk in fix:
551 assert k not in fix, (
552 f'duplicate keys {k!r} and {altk!r} in fix'
553 )
554 key = altk
555 if key is None and k in fix:
556 key = k
557 if key is None:
558 elem = numpy.zeros(v.shape, bool)
559 else:
560 elem = numpy.broadcast_to(fix[key], v.shape)
561 newfix[k] = elem
562 fix = gvar.BufferDict(newfix, dtype=bool)
563 else:
564 assert not hasattr(hyperprior, 'keys'), (
565 'fix is array but hyperprior is dictionary'
566 )
567 fix = numpy.broadcast_to(fix, hyperprior.shape).astype(bool)
569 self.fix = fix 1cba
570 return fix 1cba
572 def _parse_initial(self, hyperprior, initial, dec): 1cba
574 if not isinstance(initial, str): 574 ↛ 575line 574 didn't jump to line 575 because the condition on line 574 was never true1cba
575 self.log('start from provided point', 2)
576 initial = self._copyasarrayorbufferdict(initial)
577 if hasattr(hyperprior, 'keys'):
578 assert hasattr(initial, 'keys'), (
579 'hyperprior is dictionary but initial is array'
580 )
581 assert set(hyperprior.keys()) == set(initial.keys())
582 assert all(hyperprior[k].shape == initial[k].shape for k in hyperprior)
583 else:
584 assert not hasattr(initial, 'keys'), (
585 'hyperprior is array but initial is dictionary'
586 )
587 assert hyperprior.shape == initial.shape
589 elif initial == 'priormean': 589 ↛ 593line 589 didn't jump to line 593 because the condition on line 589 was always true1cba
590 self.log('start from prior mean', 2) 1cba
591 initial = gvar.mean(hyperprior) 1cba
593 elif initial == 'priorsample':
594 self.log('start from a random sample from the prior', 2)
595 if dec.n < hyperprior.size:
596 flathp = self._flatview(hyperprior)
597 cov = gvar.evalcov(flathp)
598 fulldec = _linalg.Chol(cov)
599 else:
600 fulldec = dec
601 iid = numpy.random.randn(fulldec.m) # noqa: NPY002, uses the global numpy state on purpose
602 flatinitial = numpy.asarray(fulldec.correlate(iid))
603 initial = self._unflatview(flatinitial, hyperprior)
605 else:
606 raise KeyError(initial)
608 self.initial = initial 1cba
609 return initial 1cba
611 def _parse_data(self, data): 1cba
613 self.data = data 1cba
614 if isinstance(data, tuple) and len(data) == 1: 1cba
615 (data,) = data 1a
617 if callable(data): 1cba
618 self.log('data is callable', 2) 1ba
619 cachedargs = None 1ba
620 elif isinstance(data, tuple): 1cba
621 self.log('data errors provided separately', 2) 1a
622 assert len(data) == 2 1a
623 cachedargs = data 1a
624 elif (gdata := self._copyasarrayorbufferdict(data)).dtype == object: 1cba
625 self.log('data has errors as gvars', 2) 1ba
626 data = gvar.gvar(gdata) 1ba
627 # convert to gvar because non-gvars in the array would upset
628 # gvar.mean and gvar.evalcov
629 cachedargs = (gvar.mean(data), gvar.evalcov(data)) 1ba
630 else:
631 self.log('data has no errors', 2) 1cba
632 cachedargs = (data,) 1cba
634 return data, cachedargs 1cba
636 def _prepare_functions( # noqa: C901, PLR0915 1cba
637 self,
638 *,
639 gpfactory,
640 gpfactorykw,
641 data,
642 cachedargs,
643 hpunflat,
644 mlkw,
645 jit,
646 forward,
647 additional_loss,
648 ):
650 timer = self._Timer() 1cba
651 firstcall = [None] 1cba
653 def make_decomp(p, **kw): 1cba
654 """Decomposition of the prior covariance and data."""
655 # start timer and convert hypers to user format
656 p = timer.start(p) 1cba
657 hp = hpunflat(p) 1cba
659 # create GP object
660 gp = gpfactory(hp, **kw) 1cba
661 assert isinstance(gp, _GP.GP) 1cba
663 # extract data
664 if cachedargs: 1cba
665 args = cachedargs 1cba
666 else:
667 args = data(hp, **kw) 1ba
668 if not isinstance(args, tuple): 1ba
669 args = (args,) 1ba
671 # decompose covariance matrix and flatten data
672 decomp, r = gp._prior_decomp(*args, covtransf=timer.partial, **mlkw) 1cba
673 r = r.astype(float) # int data upsets jax 1cba
675 # log number of datapoints
676 if firstcall: 1cba
677 # it is convenient to do here because the data is flattened.
678 # works under jit since the first call is tracing
679 firstcall.pop() 1cba
680 xdtype = gp._get_x_dtype() 1cba
681 nd = '?' if xdtype is None else _array._nd(xdtype) 1cba
682 self.log(f'{r.size} datapoints, {nd} covariates') 1cba
684 # compute user loss
685 if additional_loss is None: 1cba
686 loss = 0.0 1cba
687 else:
688 loss = additional_loss(hp) 1ba
690 # split timer and return decomposition
691 return timer.partial(decomp), r, loss 1cba
693 # define wrapper to collect call stats, pass user args, compile
694 def wrap(func): 1cba
695 if jit: 695 ↛ 697line 695 didn't jump to line 697 because the condition on line 695 was always true1cba
696 func = jax_jit(func) 1cba
697 func = functools.partial(func, **gpfactorykw) 1cba
698 return self._CountCalls(func) 1cba
700 if jit: 700 ↛ 704line 700 didn't jump to line 704 because the condition on line 700 was always true1cba
701 self.log('compile functions with jax jit', 2) 1cba
703 # log derivation method
704 modename = 'forward' if forward else 'reverse' 1cba
705 self.log(f'{modename}-mode autodiff (if used)', 2) 1cba
707 def prior(p): 1cba
708 # the marginal prior of the hyperparameters is a Normal with
709 # identity covariance matrix because p is transformed to make it so
710 return 1 / 2 * (len(p) * jnp.log(2 * jnp.pi) + p @ p) 1cba
712 def grad_prior(p): 1cba
713 return p 1cba
715 def fisher_prior(p): 1cba
716 return jnp.eye(len(p)) 1a
718 @wrap 1cba
719 def fun(p, **kw): 1cba
720 """Minus log marginal posterior of the hyperparameters (not normalized)."""
721 decomp, r, loss = make_decomp(p, **kw) 1a
722 cond, _, _, _, _ = decomp.minus_log_normal_density(r, value=True) 1a
723 post = cond + prior(p) + loss 1a
724 return timer.partial(post) 1a
726 def make_gradfwd_fisher_args(p, **kw): 1cba
727 def make_decomp_tee(p): 1ba
728 decomp, r, loss = make_decomp(p, **kw) 1ba
729 return (decomp.matrix(), r, loss), (decomp, r, loss) 1ba
731 (dK, dr, grad_loss), (decomp, r, loss) = jax.jacfwd( 1ba
732 make_decomp_tee, has_aux=True
733 )(p)
734 lkw = dict(dK=dK, dr=dr) 1ba
735 return decomp, r, lkw, loss, grad_loss 1ba
737 def make_gradrev_args(p, **kw): 1cba
738 def make_decomp_loss(p): 1cba
739 def make_decomp_r(p): 1cba
740 def make_decomp_K(p): 1cba
741 decomp, r, loss = make_decomp(p, **kw) 1cba
742 return decomp.matrix(), (decomp, r, loss) 1cba
744 _, dK_vjp, (decomp, r, loss) = jax.vjp( 1cba
745 make_decomp_K, p, has_aux=True
746 )
747 return r, (decomp, r, dK_vjp, loss) 1cba
749 _, dr_vjp, (decomp, r, dK_vjp, loss) = jax.vjp( 1cba
750 make_decomp_r, p, has_aux=True
751 )
752 return loss, (decomp, r, dK_vjp, dr_vjp, loss) 1cba
754 grad_loss, (decomp, r, dK_vjp, dr_vjp, loss) = jax.grad( 1cba
755 make_decomp_loss, has_aux=True
756 )(p)
757 unpack = lambda f: lambda x: f(x)[0] 1cba
758 dK_vjp = unpack(dK_vjp) 1cba
759 dr_vjp = unpack(dr_vjp) 1cba
760 lkw = dict(dK_vjp=dK_vjp, dr_vjp=dr_vjp) 1cba
761 return decomp, r, lkw, loss, grad_loss 1cba
763 def make_jac_args(p, **kw): 1cba
764 if forward: 1cba
765 out = make_gradfwd_fisher_args(p, **kw) 1ba
766 out[2].update(gradfwd=True) # out[2] is lkw 1ba
767 else:
768 out = make_gradrev_args(p, **kw) 1cba
769 out[2].update(gradrev=True) 1cba
770 return out 1cba
772 @wrap 1cba
773 def fun_and_jac(p, **kw): 1cba
774 """`fun` and its gradient."""
775 decomp, r, lkw, loss, grad_loss = make_jac_args(p, **kw) 1cba
776 cond, gradrev, gradfwd, _, _ = decomp.minus_log_normal_density( 1cba
777 r, value=True, **lkw
778 )
779 post = cond + prior(p) + loss 1cba
780 grad_cond = gradfwd if forward else gradrev 1cba
781 grad_post = grad_cond + grad_prior(p) + grad_loss 1cba
782 return timer.partial((post, grad_post)) 1cba
784 @wrap 1cba
785 def jac(p, **kw): 1cba
786 """Gradient of fun."""
787 decomp, r, lkw, _, grad_loss = make_jac_args(p, **kw)
788 _, gradrev, gradfwd, _, _ = decomp.minus_log_normal_density(r, **lkw)
789 grad_cond = gradfwd if forward else gradrev
790 grad_post = grad_cond + grad_prior(p) + grad_loss
791 return timer.partial(grad_post)
793 @wrap 1cba
794 def fisher(p, **kw): 1cba
795 """Fisher matrix."""
796 if additional_loss is not None: 1a
797 msg = ( 1a
798 'Fisher matrix not implemented with additional_loss. It '
799 'is possible but I did not prioritize it. If you need it, '
800 'open an issue on github.'
801 )
802 raise NotImplementedError(msg) 1a
803 decomp, r, lkw, _, _ = make_gradfwd_fisher_args(p, **kw) 1a
804 _, _, _, fisher_cond, _ = decomp.minus_log_normal_density( 1a
805 r, fisher=True, **lkw
806 )
807 fisher_post = fisher_cond + fisher_prior(p) 1a
808 return timer.partial(fisher_post) 1a
810 # set attributes
811 self.gpfactory = gpfactory 1cba
812 self.gpfactorykw = gpfactorykw 1cba
814 return timer, {'fun': fun, 'jac': jac, 'fun&jac': fun_and_jac, 'fisher': fisher} 1cba
816 def _prepare_minargs(self, method, functions, hpinitial): 1cba
817 minargs = dict(fun=functions['fun&jac'], jac=True, x0=hpinitial) 1cba
818 if self.SEPARATE_JAC: 818 ↛ 819line 818 didn't jump to line 819 because the condition on line 818 was never true1cba
819 minargs.update(fun=functions['fun'], jac=functions['jac'])
820 if method == 'nograd': 1cba
821 minargs.update(fun=functions['fun'], jac=None, method='nelder-mead') 1a
822 elif method == 'gradient': 1cba
823 minargs.update(method='bfgs') 1cba
824 elif method == 'fisher': 1a
825 minargs.update(hess=functions['fisher'], method='dogleg') 1a
826 # dogleg requires positive definiteness, fisher is p.s.d.
827 # trust-constr has more options, but it seems to be slower than
828 # dogleg, so I keep dogleg as default
829 else:
830 raise KeyError(method) 1a
831 self.log(f'method {method!r}', 2) 1cba
832 return minargs 1cba
834 def _log_totals(self, total, timer, callback, jit, functions): 1cba
835 times = { 1cba
836 'gp&cov': timer.totals[0],
837 'decomp': timer.totals[1],
838 'likelihood': timer.totals[2],
839 'jit': None, # set now and delete later to keep it before 'other'
840 'other': total - sum(timer.totals.values()),
841 }
842 if jit: 842 ↛ 844line 842 didn't jump to line 844 because the condition on line 842 was always true1cba
843 overhead = callback.estimate_firstcall_overhead() 1cba
844 if jit and overhead is not None: 1cba
845 times['jit'] = overhead 1cba
846 times['other'] -= overhead 1cba
847 else:
848 del times['jit'] 1a
849 self.log('', 4) 1cba
850 calls = self._CountCalls.fmtcalls('total', functions) 1cba
851 self.log(f'calls: {calls}') 1cba
852 self.log(f'total time: {callback.fmttime(total)}') 1cba
853 self.log(f'partials: {callback.fmttimes(times)}', 2) 1cba
855 def _check_success(self, result, raises): 1cba
856 if result.success: 1cba
857 self.log(f'minimization succeeded: {result.message}') 1cba
858 else:
859 msg = f'minimization failed: {result.message}' 1ca
860 if raises: 1ca
861 raise RuntimeError(msg) 1a
862 elif self._verbosity == 0: 1ca
863 warnings.warn(msg) 1ca
864 else:
865 self.log(msg) 1a
867 def _posterior_covariance(self, method, covariance, minimizer_result, fisher_func): # noqa: C901 1cba
869 if covariance == 'auto': 869 ↛ 877line 869 didn't jump to line 877 because the condition on line 869 was always true1cba
870 if hasattr(minimizer_result, 'hess_inv') or hasattr( 1cba
871 minimizer_result, 'hess'
872 ):
873 covariance = 'minhess' 1cba
874 else:
875 covariance = 'none' 1a
877 if covariance == 'fisher': 877 ↛ 878line 877 didn't jump to line 878 because the condition on line 877 was never true1cba
878 self.log('use fisher plus prior precision as precision', 2)
879 if method == 'fisher':
880 prec = minimizer_result.hess
881 else:
882 prec = fisher_func(minimizer_result.x)
883 cov = _linalg.Chol(prec).ginv()
885 elif covariance == 'minhess': 1cba
886 if hasattr(minimizer_result, 'hess_inv'): 1cba
887 hessinv = minimizer_result.hess_inv 1cba
888 if isinstance(hessinv, optimize.LbfgsInvHessProduct): 1cba
889 self.log( 1ba
890 f'convert LBFGS({hessinv.n_corrs}) hessian inverse to BFGS as covariance',
891 2,
892 )
893 cov = self._invhess_lbfgs_to_bfgs(hessinv) 1ba
894 elif isinstance(hessinv, numpy.ndarray): 894 ↛ 912line 894 didn't jump to line 912 because the condition on line 894 was always true1cba
895 self.log( 1cba
896 'use minimizer estimate of inverse hessian as covariance', 2
897 )
898 cov = hessinv 1cba
899 elif hasattr(minimizer_result, 'hess'): 899 ↛ 903line 899 didn't jump to line 903 because the condition on line 899 was always true1a
900 self.log('use minimizer hessian as precision', 2) 1a
901 cov = _linalg.Chol(minimizer_result.hess).ginv() 1a
902 else:
903 msg = 'the minimizer did not return an estimate of the hessian'
904 raise RuntimeError(msg)
906 elif covariance == 'none': 906 ↛ 910line 906 didn't jump to line 910 because the condition on line 906 was always true1a
907 cov = numpy.full(minimizer_result.x.size, numpy.nan) 1a
909 else:
910 raise KeyError(covariance)
912 return cov 1cba
914 @staticmethod 1cba
915 def _invhess_lbfgs_to_bfgs(lbfgs): 1cba
916 bfgs = optimize.BFGS() 1ba
917 bfgs.initialize(lbfgs.shape[0], 'inv_hess') 1ba
918 for i in range(lbfgs.n_corrs): 1ba
919 bfgs.update(lbfgs.sk[i], lbfgs.yk[i]) 1ba
920 return bfgs.get_matrix() 1ba
922 class _Callback: 1cba
923 """Iteration callback for scipy.optimize.minimize."""
925 def __init__(self, this, functions, timer, unflat): 1cba
926 self.it = 0 1cba
927 self.stamp = time.perf_counter() 1cba
928 self.this = this 1cba
929 self.functions = functions 1cba
930 self.timer = timer 1cba
931 self.unflat = unflat 1cba
932 self.tail_overhead = 0 1cba
933 self.tail_overhead_iter = 0 1cba
935 def __call__(self, intermediate_result, arg2=None): # noqa: ARG002, scipy's callback signature 1cba
937 if isinstance(intermediate_result, optimize.OptimizeResult): 937 ↛ 938line 937 didn't jump to line 938 because the condition on line 937 was never true1cba
938 p = intermediate_result.x
939 elif isinstance(intermediate_result, numpy.ndarray): 939 ↛ 942line 939 didn't jump to line 942 because the condition on line 939 was always true1cba
940 p = intermediate_result 1cba
941 else:
942 raise TypeError(type(intermediate_result))
944 self.it += 1 1cba
945 now = time.perf_counter() 1cba
946 duration = now - self.stamp 1cba
948 worktime = sum(self.timer.partials.values()) 1cba
949 if worktime: 1cba
950 overhead = duration - worktime 1cba
951 assert overhead >= 0, (duration, worktime) 1cba
952 if self.it == 1: 1cba
953 self.first_overhead = overhead 1cba
954 else:
955 self.tail_overhead_iter += 1 1cba
956 self.tail_overhead += overhead 1cba
958 # level 3 log
959 calls = self.this._CountCalls.fmtcalls('partial', self.functions) 1cba
960 times = self.fmttime(duration) 1cba
961 self.this.log(f'iter {self.it}, time: {times}, calls: {calls}', {3}) 1cba
963 # level 4 log
964 tot = self.fmttime(duration) 1cba
965 if self.timer.partials: 1cba
966 times = { 1cba
967 'gp&cov': self.timer.partials[0],
968 'dec': self.timer.partials[1],
969 'like': self.timer.partials[2],
970 'other': duration - sum(self.timer.partials.values()),
971 }
972 times = self.fmttimes(times) 1cba
973 else:
974 times = 'n/d' 1a
975 self.this.log(f'\niteration {self.it}', 4) 1cba
976 with self.this.loglevel: 1cba
977 self.this.log(f'total time: {tot}', 4) 1cba
978 self.this.log(f'partial: {times}', 4) 1cba
979 self.this.log(f'calls: {calls}', 4) 1cba
981 # level 5 log
982 nicep = self.unflat(p) 1cba
983 nicep = self.this._copyasarrayorbufferdict(nicep) 1cba
984 with self.this.loglevel: 1cba
985 self.this.log(f'parameters = {nicep}', 5) 1cba
987 self.stamp = now 1cba
988 self.timer.reset() 1cba
990 pattern = re.compile(r'((\d+) days, )?(\d{1,2}):(\d\d):(\d\d(\.\d{6})?)') 1cba
992 @classmethod 1cba
993 def fmttime(cls, seconds): 1cba
994 if seconds < 0: 994 ↛ 995line 994 didn't jump to line 995 because the condition on line 994 was never true1cba
995 prefix = '-'
996 seconds = -seconds
997 else:
998 prefix = '' 1cba
999 return prefix + cls._fmttime_positive(seconds) 1cba
1001 @classmethod 1cba
1002 def _fmttime_positive(cls, seconds): 1cba
1003 td = datetime.timedelta(seconds=seconds) 1cba
1004 m = cls.pattern.fullmatch(str(td)) 1cba
1005 _, day, hour, minute, second, _ = m.groups() 1cba
1006 hour = int(hour) 1cba
1007 minute = int(minute) 1cba
1008 second = float(second) 1cba
1009 if day: 1009 ↛ 1010line 1009 didn't jump to line 1010 because the condition on line 1009 was never true1cba
1010 return f'{day.lstrip("0")}d{hour:02d}h'
1011 elif hour: 1011 ↛ 1012line 1011 didn't jump to line 1012 because the condition on line 1011 was never true1cba
1012 return f'{hour}h{minute:02d}m'
1013 elif minute: 1cba
1014 return f'{minute}m{second:02.0f}s' 1b
1015 elif second >= 0.0995: 1cba
1016 return f'{second:#.2g}'.rstrip('.') + 's' 1cba
1017 elif second >= 0.0000995: 1cba
1018 return f'{second * 1e3:#.2g}'.rstrip('.') + 'ms' 1cba
1019 else:
1020 return f'{second * 1e6:.0f}μs' 1cba
1022 @classmethod 1cba
1023 def fmttimes(cls, times): 1cba
1024 """Format `times` = dict label -> seconds."""
1025 return ', '.join(f'{k} {cls.fmttime(v)}' for k, v in times.items()) 1cba
1027 def estimate_firstcall_overhead(self): 1cba
1028 if self.tail_overhead_iter and hasattr(self, 'first_overhead'): 1cba
1029 typical_overhead = self.tail_overhead / self.tail_overhead_iter 1cba
1030 return self.first_overhead - typical_overhead 1cba
1031 return None 1a
1033 @staticmethod 1cba
1034 def _copyasarrayorbufferdict(x): 1cba
1035 if hasattr(x, 'keys'): 1cba
1036 return gvar.BufferDict(x) 1cba
1037 else:
1038 return numpy.array(x) 1a
1040 @staticmethod 1cba
1041 def _flatview(x): 1cba
1042 if hasattr(x, 'reshape'): 1cba
1043 return x.reshape(-1) 1a
1044 elif hasattr(x, 'buf'): 1cba
1045 return x.buf 1cba
1046 else: # pragma: no cover
1047 raise NotImplementedError
1049 @staticmethod 1cba
1050 def _unflatview(x, original): 1cba
1051 if isinstance(original, numpy.ndarray): 1cba
1052 return x.reshape(original.shape) 1a
1053 # if not out.shape:
1054 # try:
1055 # out = out.item()
1056 # except jax.errors.ConcretizationTypeError:
1057 # pass
1058 elif isinstance(original, gvar.BufferDict): 1cba
1059 # normally I would do BufferDict(original, buf=x) but it does not
1060 # work with JAX tracers
1061 b = gvar.BufferDict(original) 1cba
1062 b._extension = {} 1cba
1063 b._buf = x 1cba
1064 # b.buf = x does not work because BufferDict checks that the
1065 # array is a numpy array
1066 return b 1cba
1067 else: # pragma: no cover
1068 raise NotImplementedError