Source code for modelly.calibrate.bayesopt

"""Bayesian optimisation of the targets' distance: a basic Gaussian-process search.

A built-in, deliberately small optimiser for models too expensive for ABC:
``initial`` points of a Latin hypercube over the priors' unit box, then, one
point at a time, the candidate with the largest expected improvement under a
Gaussian process (:mod:`modelly.calibrate.gp`) fitted to every evaluation so
far. Candidates are random points of the box from a seeded stream, so a run
is reproducible. It returns the best point found, not a posterior
distribution, and suits a handful of parameters and up to a few hundred
evaluations; no external optimisation library is needed.

Example:
    >>> search = BayesOpt(Epidemic, priors, targets, steps=60, replicates=4, seed=3)  # doctest: +SKIP
    >>> search.run(evaluations=40).best  # doctest: +SKIP
"""

from __future__ import annotations

from typing import Any

import numpy as np

from modelly.calibrate.gp import GaussianProcess, expected_improvement
from modelly.calibrate.optimize import OBJECTIVE, PointEstimate
from modelly.calibrate.posterior import Posterior
from modelly.calibrate.simulator import DEFAULT_EXECUTOR, Execution, Stream, generator

DEFAULT_EVALUATIONS = 30
DEFAULT_INITIAL = 10
CANDIDATES = 2000


[docs] class BayesOpt(PointEstimate): """Gaussian-process Bayesian optimisation of the total distance (see the module docstring).""" method = "bayes-opt" key = 4 def _evaluate(self, units: np.ndarray, execution: Execution) -> None: """Run the points ``units`` and record their total distances.""" distances, summaries = self.measure(self.simulate(units, execution)) for i, unit in enumerate(units): self.record(unit, distances[i], {n: s[i] for n, s in summaries.items()}) def _initial(self, count: int) -> np.ndarray: """``count`` Latin-hypercube points of the unit box, from the design stream.""" from scipy.stats import qmc # noqa: PLC0415 - lazy import rng = generator(self.seed, Stream.DESIGN, self.key) return qmc.LatinHypercube(len(self.names), rng=rng).random(count) def _next(self, iteration: int) -> np.ndarray: """The candidate of largest expected improvement under the fitted process.""" x = np.vstack([entry["unit"] for entry in self.evaluations]) y = np.array([entry["row"][OBJECTIVE] for entry in self.evaluations]) process = GaussianProcess.fit(x, y) rng = generator(self.seed, Stream.PROPOSAL, self.key, iteration) candidates = rng.random((CANDIDATES, len(self.names))) mean, sd = process.predict(candidates) return candidates[ int(np.argmax(expected_improvement(mean, sd, float(y.min())))) ]
[docs] def run( self, evaluations: int = DEFAULT_EVALUATIONS, initial: int = DEFAULT_INITIAL, executor: Any = DEFAULT_EXECUTOR, **options: Any, ) -> Posterior: """``initial`` design points (run together), then the rest one by one; the best point. ``max_parallel`` and executor options go in ``options``. """ if not 1 <= initial <= evaluations: raise ValueError("need 1 <= initial <= evaluations") max_parallel = options.pop("max_parallel", None) self.evaluations = [] with Execution(executor, max_parallel, options) as execution: self._evaluate(self._initial(initial), execution) for iteration in range(evaluations - initial): self._evaluate(self._next(iteration)[None, :], execution) return self.estimate()