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

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

19 

20import datetime 1cba

21import functools 1cba

22import re 1cba

23import textwrap 1cba

24import time 1cba

25import warnings 1cba

26 

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

35 

36from lsqfitgp import _GP, _array, _gvarext, _linalg 1cba

37 

38 

39@functools.singledispatch 1cba

40def token_getter(x): 1cba

41 return x 

42 

43 

44@functools.singledispatch 1cba

45def token_setter(x, token): # noqa: ARG001, the dispatch argument 1cba

46 return token 

47 

48 

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

54 

55 

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

62 

63 

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

67 

68 @jax.custom_jvp 1cba

69 def jaxfunc(token): 1cba

70 return jax.pure_callback(func, token, token, vmap_method='expand_dims') 1cba

71 

72 @jaxfunc.defjvp 1cba

73 def _(p, t): 1cba

74 return (jaxfunc(*p), *t) 1cba

75 

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) 

82 

83 

84def token_map(func, x): 1cba

85 return tree.map(lambda x: token_map_leaf(func, x), x) 1cba

86 

87 

88class Logger: 1cba

89 """ 

90 Class to manage a log. 

91 

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 """ 

96 

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

101 

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

107 

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

115 

116 def log(self, message, verbosity=1, *, level=0): 1cba

117 """ 

118 Print and record a message. 

119 

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

134 

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 ) 

142 

143 class _LogLevel: 1cba

144 """Shared context manager to indent messages.""" 

145 

146 _level = 0 1cba

147 

148 @classmethod 1cba

149 def __enter__(cls): 1cba

150 cls._level += 1 1cba

151 

152 @classmethod 1cba

153 def __exit__(cls, *_): 1cba

154 cls._level -= 1 1cba

155 

156 loglevel = _LogLevel() 1cba

157 

158 

159class empbayes_fit(Logger): 1cba

160 SEPARATE_JAC = False 1cba

161 

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. 

183 

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. 

187 

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: 

219 

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: 

230 

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: 

238 

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: 

254 

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. 

278 

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. 

306 

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

315 

316 assert callable(gpfactory) 1cba

317 

318 # analyze the hyperprior 

319 hpinitial, hpunflat = self._parse_hyperprior(hyperprior, initial, fix) 1cba

320 del hyperprior, initial, fix 1cba

321 

322 # analyze data 

323 data, cachedargs = self._parse_data(data) 1cba

324 

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

338 

339 # prepare minimizer arguments 

340 minargs = self._prepare_minargs(method, functions, hpinitial) 1cba

341 

342 # set up callback to time and log iterations 

343 callback = self._Callback(self, functions, timer, hpunflat) 1cba

344 minargs.update(callback=callback) 1cba

345 

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) 

349 

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

355 

356 # check the minimization was successful 

357 self._check_success(result, raises) 1cba

358 

359 # compute posterior covariance of the hyperparameters 

360 cov = self._posterior_covariance( 1cba

361 method, covariance, result, functions['fisher'] 

362 ) 

363 

364 # log total timings and function calls 

365 total = time.perf_counter() - total 1cba

366 self._log_totals(total, timer, callback, jit, functions) 1cba

367 

368 ##### temporary fix for gplepage/gvar#50 ##### 

369 cov = numpy.array(cov, order='C') 1cba

370 ############################################## 

371 

372 # join posterior mean and covariance matrix 

373 uresult = gvar.gvar(result.x, cov) 1cba

374 

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

381 

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 ) 

389 

390 self.log('**** exit lsqfitgp.empbayes_fit ****') 1cba

391 

392 class _CountCalls: 1cba

393 """Wrap a callable to count calls.""" 

394 

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

400 

401 def __call__(self, *args, **kw): 1cba

402 self._total += 1 1cba

403 self._partial += 1 1cba

404 return self._func(*args, **kw) 1cba

405 

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

411 

412 def total(self): 1cba

413 """Return the total number of calls.""" 

414 return self._total 1cba

415 

416 @staticmethod 1cba

417 def fmtcalls(method, functions): 1cba

418 """ 

419 Format summary of number of calls. 

420 

421 method : str 

422 functions: dict[str, _CountCalls]. 

423 """ 

424 

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

429 

430 return ', '.join(counts()) 1cba

431 

432 class _Timer: 1cba

433 """Object to time likelihood computations.""" 

434 

435 def __init__(self): 1cba

436 self.totals = {} 1cba

437 self.partials = {} 1cba

438 self._last_start = False 1cba

439 

440 def start(self, token): 1cba

441 return token_map(self._start, token) 1cba

442 

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

449 

450 def reset(self): 1cba

451 self.partials = {} 1cba

452 

453 def partial(self, token): 1cba

454 return token_map(self._partial, token) 1cba

455 

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

465 

466 def _parse_hyperprior(self, hyperprior, initial, fix): 1cba

467 

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

473 

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

482 

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

487 

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

493 

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

510 

511 self.prior = hyperprior 1cba

512 return x0, unflat 1cba

513 

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) 

525 

526 def _parse_fix(self, hyperprior, fix): 1cba

527 

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) 

568 

569 self.fix = fix 1cba

570 return fix 1cba

571 

572 def _parse_initial(self, hyperprior, initial, dec): 1cba

573 

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 

588 

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

592 

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) 

604 

605 else: 

606 raise KeyError(initial) 

607 

608 self.initial = initial 1cba

609 return initial 1cba

610 

611 def _parse_data(self, data): 1cba

612 

613 self.data = data 1cba

614 if isinstance(data, tuple) and len(data) == 1: 1cba

615 (data,) = data 1a

616 

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

633 

634 return data, cachedargs 1cba

635 

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

649 

650 timer = self._Timer() 1cba

651 firstcall = [None] 1cba

652 

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

658 

659 # create GP object 

660 gp = gpfactory(hp, **kw) 1cba

661 assert isinstance(gp, _GP.GP) 1cba

662 

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

670 

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

674 

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

683 

684 # compute user loss 

685 if additional_loss is None: 1cba

686 loss = 0.0 1cba

687 else: 

688 loss = additional_loss(hp) 1ba

689 

690 # split timer and return decomposition 

691 return timer.partial(decomp), r, loss 1cba

692 

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

699 

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

702 

703 # log derivation method 

704 modename = 'forward' if forward else 'reverse' 1cba

705 self.log(f'{modename}-mode autodiff (if used)', 2) 1cba

706 

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

711 

712 def grad_prior(p): 1cba

713 return p 1cba

714 

715 def fisher_prior(p): 1cba

716 return jnp.eye(len(p)) 1a

717 

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

725 

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

730 

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

736 

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

743 

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

748 

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

753 

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

762 

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

771 

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

783 

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) 

792 

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

809 

810 # set attributes 

811 self.gpfactory = gpfactory 1cba

812 self.gpfactorykw = gpfactorykw 1cba

813 

814 return timer, {'fun': fun, 'jac': jac, 'fun&jac': fun_and_jac, 'fisher': fisher} 1cba

815 

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

833 

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

854 

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

866 

867 def _posterior_covariance(self, method, covariance, minimizer_result, fisher_func): # noqa: C901 1cba

868 

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

876 

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

884 

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) 

905 

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

908 

909 else: 

910 raise KeyError(covariance) 

911 

912 return cov 1cba

913 

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

921 

922 class _Callback: 1cba

923 """Iteration callback for scipy.optimize.minimize.""" 

924 

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

934 

935 def __call__(self, intermediate_result, arg2=None): # noqa: ARG002, scipy's callback signature 1cba

936 

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

943 

944 self.it += 1 1cba

945 now = time.perf_counter() 1cba

946 duration = now - self.stamp 1cba

947 

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

957 

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

962 

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

980 

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

986 

987 self.stamp = now 1cba

988 self.timer.reset() 1cba

989 

990 pattern = re.compile(r'((\d+) days, )?(\d{1,2}):(\d\d):(\d\d(\.\d{6})?)') 1cba

991 

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

1000 

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

1021 

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

1026 

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

1032 

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

1039 

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 

1048 

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