diff --git a/docs/algorithms.md b/docs/algorithms.md index dccf210bc..8d8517a8c 100644 --- a/docs/algorithms.md +++ b/docs/algorithms.md @@ -65,7 +65,8 @@ Scipy Generators These generators serve as wrappers for algorithms implemented in scipy. - [`NelderMeadGenerator`](examples/sequential/neldermead.ipynb): implements Nelder-Mead (simplex) optimization. -- [`LatinHypercubeGenerator`](examples/scipy/latin_hypercube.ipynb): perform latin hypercube sampling of the evaluation function. +- [`ScipyGenerator`](examples/sequential/scipy.ipynb): generic wrapper around `scipy.optimize.minimize` methods. +- [`LatinHypercubeGenerator`](examples/other/latin_hypercube.ipynb): performs latin hypercube sampling of the evaluation function. RCDS Generators === diff --git a/docs/api/generators/sequential/scipy.md b/docs/api/generators/sequential/scipy.md new file mode 100644 index 000000000..012053724 --- /dev/null +++ b/docs/api/generators/sequential/scipy.md @@ -0,0 +1,64 @@ +# Scipy Minimize Generator + +`ScipyGenerator` exposes scipy's `optimize.minimize` methods through Xopt's sequential ask/tell interface. + +## Integration Model + +Xopt evaluates objective functions externally, one point at a time. `scipy.optimize.minimize` expects an in-process callable objective. `ScipyGenerator` bridges this mismatch by running one persistent scipy session in a worker thread: + +1. A cache is built from prior evaluations (`data`) keyed by rounded variable values. +2. A single `minimize` call starts in a background thread. +3. The objective callback first checks the cache. +4. For an uncached point, that point is pushed to Xopt via a request queue. +5. Xopt evaluates the point externally and calls `add_data`. +6. The objective value is sent back through a response queue, and the same scipy run continues from in-memory state. + +## Performance Notes + +- Cache reconstruction is O(N) when a session starts or data is reloaded. +- The active scipy run is maintained between `step` calls; `minimize` is not restarted each step. +- Keys are rounded to 12 decimals before cache lookup to reduce floating-point key mismatch issues. + +## Supported Methods + +`method` is validated against bounded scipy methods supported by this wrapper: + +- `Nelder-Mead` +- `Powell` +- `L-BFGS-B` +- `TNC` +- `SLSQP` +- `trust-constr` +- `COBYLA` +- `COBYQA` + +Invalid or empty method names fail validation. + +## Session Lifecycle and Errors + +- `reset()` stops the active worker session and clears transient runtime state. +- `set_data(...)` stops any active session, reloads data, and rebuilds the cache. +- If scipy converges using only cached values, generation falls back to the latest known data row. +- Common scipy `ValueError` messages are remapped to clearer runtime errors (for example unsupported solver availability or bound handling). + +## State Restoration + +For model-level roundtrips, use pydantic serialization: + +- `model_dump()` +- `model_validate(...)` + +Then restore the evaluation history with `set_data(...)` so the cache and last outcome are reconstructed before continuing optimization. + +The class also defines `__getstate__` and `__setstate__` for explicit state handling of non-picklable runtime thread objects. + +## Configuration + +Typical fields: + +- `method`: scipy minimization method name, e.g. `Powell`, `Nelder-Mead`, `L-BFGS-B`. +- `initial_point`: optional starting point dictionary. +- `tol`, `options`: passed directly to `scipy.optimize.minimize`. +- `scipy_kwargs`: additional keyword arguments forwarded to scipy. + +::: xopt.generators.sequential.scipy diff --git a/docs/examples/scipy/latin_hypercube.ipynb b/docs/examples/other/latin_hypercube.ipynb similarity index 100% rename from docs/examples/scipy/latin_hypercube.ipynb rename to docs/examples/other/latin_hypercube.ipynb diff --git a/docs/examples/sequential/scipy.ipynb b/docs/examples/sequential/scipy.ipynb new file mode 100644 index 000000000..8ab1c9982 --- /dev/null +++ b/docs/examples/sequential/scipy.ipynb @@ -0,0 +1,161 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "3841297f", + "metadata": {}, + "source": [ + "# ScipyGenerator with scipy.optimize.minimize\n", + "\n", + "This notebook demonstrates how to use Xopt's `ScipyGenerator` to drive any supported scipy `optimize.minimize` method in a sequential ask/tell workflow." + ] + }, + { + "cell_type": "markdown", + "id": "ece077aa", + "metadata": {}, + "source": [ + "## Imports" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2517dcff", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "from xopt import Evaluator, VOCS, Xopt\n", + "from xopt.generators.sequential.scipy import ScipyGenerator" + ] + }, + { + "cell_type": "markdown", + "id": "ca499090", + "metadata": {}, + "source": [ + "## Define a simple objective\n", + "\n", + "We will optimize the 2D Rosenbrock function, exposed through an Xopt evaluator function." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7702f19c", + "metadata": {}, + "outputs": [], + "source": [ + "def rosenbrock_eval(input_dict):\n", + " x0 = input_dict[\"x0\"]\n", + " x1 = input_dict[\"x1\"]\n", + " y = (1 - x0) ** 2 + 100.0 * (x1 - x0**2) ** 2\n", + " return {\"y\": float(y)}" + ] + }, + { + "cell_type": "markdown", + "id": "29a8f4d5", + "metadata": {}, + "source": [ + "## Configure `ScipyGenerator`\n", + "\n", + "Choose a scipy method using `method`. Here we use `L-BFGS-B`, but methods such as `Nelder-Mead`, `Powell`, and others can also be used." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b020f9b7", + "metadata": {}, + "outputs": [], + "source": [ + "vocs = VOCS(\n", + " variables={\"x0\": [-2.0, 2.0], \"x1\": [-1.0, 3.0]},\n", + " objectives={\"y\": \"MINIMIZE\"},\n", + ")\n", + "\n", + "generator = ScipyGenerator(\n", + " vocs=vocs,\n", + " method=\"L-BFGS-B\",\n", + " initial_point={\"x0\": -1.2, \"x1\": 1.0},\n", + " options={\"maxiter\": 200},\n", + ")\n", + "\n", + "evaluator = Evaluator(function=rosenbrock_eval)\n", + "X = Xopt(generator=generator, evaluator=evaluator, vocs=vocs)" + ] + }, + { + "cell_type": "markdown", + "id": "6b3fe58b", + "metadata": {}, + "source": [ + "## Run optimization" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "775e0fe9", + "metadata": {}, + "outputs": [], + "source": [ + "for _ in range(200):\n", + " X.step()\n", + "\n", + "best_idx = X.data[\"y\"].argmin()\n", + "best = X.data.iloc[best_idx]\n", + "\n", + "print(\"Evaluations:\", len(X.data))\n", + "print(\"Best point:\", {\"x0\": float(best[\"x0\"]), \"x1\": float(best[\"x1\"])})\n", + "print(\"Best objective:\", float(best[\"y\"]))" + ] + }, + { + "cell_type": "markdown", + "id": "444a1f87", + "metadata": {}, + "source": [ + "## Inspect convergence" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a5518499", + "metadata": {}, + "outputs": [], + "source": [ + "ax = X.data[\"y\"].plot(figsize=(7, 4), logy=True)\n", + "ax.set_xlabel(\"iteration\")\n", + "ax.set_ylabel(\"objective y (log scale)\")\n", + "ax.set_title(\"ScipyGenerator optimization progression\")\n", + "plt.tight_layout()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "xopt-dev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/mkdocs.yml b/mkdocs.yml index 440a84a42..2a6c86954 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,11 +62,12 @@ nav: - Genetic operators: examples/ga/nsga2/genetic_operators.ipynb - Sequential: + - Scipy Minimize: examples/sequential/scipy.ipynb - Nelder-Mead: examples/sequential/neldermead.ipynb - Extremum seeking: examples/sequential/extremum_seeking.ipynb - RCDS: examples/sequential/rcds.ipynb - Other: - - Latin Hypercube: examples/scipy/latin_hypercube.ipynb + - Latin Hypercube: examples/other/latin_hypercube.ipynb - Developer: - Benchmarking & Profiling: examples/developer/benchmarking.md @@ -104,6 +105,7 @@ nav: - Genetic Operators: api/generators/ga/operators.md - Sequential generators: - Sequential Base Class: api/generators/sequential/sequential_generator.md + - Scipy Minimize: api/generators/sequential/scipy.md - RCDS: api/generators/sequential/rcds.md - Extremum Seeking: api/generators/sequential/extremumseeking.md - Nelder-Mead: api/generators/sequential/neldermead.md diff --git a/xopt/generators/__init__.py b/xopt/generators/__init__.py index 42ed1b43b..c14e62cdf 100644 --- a/xopt/generators/__init__.py +++ b/xopt/generators/__init__.py @@ -15,7 +15,7 @@ # don't import this directly -- use all_generator_names = { "mggpo": {"mggpo"}, - "scipy": {"neldermead", "latin_hypercube"}, + "scipy": {"neldermead", "latin_hypercube", "scipy"}, "bo": { "upper_confidence_bound", "mobo", @@ -61,9 +61,11 @@ def get_generator_dynamic(name: str) -> type[Generator]: try: from xopt.generators.scipy.latin_hypercube import LatinHypercubeGenerator from xopt.generators.sequential.neldermead import NelderMeadGenerator + from xopt.generators.sequential.scipy import ScipyGenerator registered_generators = [ NelderMeadGenerator, + ScipyGenerator, LatinHypercubeGenerator, ] @@ -159,6 +161,8 @@ def get_generator_defaults( if v.is_required(): defaults[k] = None + elif v.default_factory is not None: + defaults[k] = v.default_factory() else: if v.default is None: defaults[k] = None diff --git a/xopt/generators/scipy/__init__.py b/xopt/generators/scipy/__init__.py index 6b92bf095..e69de29bb 100644 --- a/xopt/generators/scipy/__init__.py +++ b/xopt/generators/scipy/__init__.py @@ -1,4 +0,0 @@ -from xopt.generators.scipy.latin_hypercube import LatinHypercubeGenerator -from xopt.generators.sequential.neldermead import NelderMeadGenerator - -__all__ = ["LatinHypercubeGenerator", "NelderMeadGenerator"] diff --git a/xopt/generators/sequential/__init__.py b/xopt/generators/sequential/__init__.py index 4c3fff857..9a9bf6310 100644 --- a/xopt/generators/sequential/__init__.py +++ b/xopt/generators/sequential/__init__.py @@ -2,10 +2,12 @@ from xopt.generators.sequential.rcds import RCDSGenerator from xopt.generators.sequential.extremumseeking import ExtremumSeekingGenerator from xopt.generators.sequential.neldermead import NelderMeadGenerator +from xopt.generators.sequential.scipy import ScipyGenerator __all__ = [ "SequentialGenerator", "RCDSGenerator", "ExtremumSeekingGenerator", "NelderMeadGenerator", + "ScipyGenerator", ] diff --git a/xopt/generators/sequential/scipy.py b/xopt/generators/sequential/scipy.py new file mode 100644 index 000000000..7db08d73c --- /dev/null +++ b/xopt/generators/sequential/scipy.py @@ -0,0 +1,347 @@ +from queue import Empty, Queue +from threading import Event, Lock, Thread +from typing import Any, Dict, List, Optional + +import numpy as np +import pandas as pd +from pydantic import Field, PrivateAttr, field_validator +from scipy.optimize import minimize + +from xopt.generators.sequential.sequential_generator import SequentialGenerator +from xopt.vocs import get_objective_data, get_variable_data + +BOUNDED_METHODS = [ + "Nelder-Mead", + "Powell", + "L-BFGS-B", + "TNC", + "SLSQP", + "trust-constr", + "COBYLA", + "COBYQA", +] + + +class _StopSession(Exception): + """Internal exception used to terminate the persistent minimize session.""" + + +class ScipyGenerator(SequentialGenerator): + """ + Sequential wrapper around ``scipy.optimize.minimize``. + + Integration model + ----------------- + Xopt uses ask/tell semantics (one new point per ``step``), while scipy + ``minimize`` expects direct access to objective evaluations. This class bridges + the two by running one persistent ``minimize`` call in a worker thread: + + 1. Build a cache from existing Xopt observations. + 2. Start ``scipy.optimize.minimize`` once with an objective function that first + checks the cache. + 3. If scipy asks for an unseen point, send that point to Xopt and block until + Xopt provides the external objective value. + 4. Continue the same scipy run from in-memory state until convergence. + + Performance implications + ------------------------ + - ``_build_cache`` is O(N) in number of past evaluations and is executed when the + persistent session starts. + - The active scipy run is maintained in memory between ``step`` calls. + - Point keys are rounded (12 decimals) before cache lookup to avoid fragile + floating-point equality checks. + + """ + + name = "scipy" + supports_single_objective: bool = True + + method: str = Field( + "Powell", + description="Method name passed to scipy.optimize.minimize (e.g. 'Powell', 'Nelder-Mead').", + ) + initial_point: Optional[Dict[str, float]] = None + tol: Optional[float] = Field( + 1e-8, description="Termination tolerance passed to scipy.optimize.minimize" + ) + options: Dict[str, Any] = Field( + default_factory=dict, + description="Options dictionary passed directly to scipy.optimize.minimize", + ) + scipy_kwargs: Dict[str, Any] = Field( + default_factory=dict, + description="Additional keyword arguments passed to scipy.optimize.minimize.", + ) + + # Internal state + _last_outcome: Optional[float] = None + _cache: Dict[tuple, float] = PrivateAttr(default_factory=dict) + _cache_lock: Lock = PrivateAttr(default_factory=Lock) + _request_queue: Queue = PrivateAttr(default_factory=Queue) + _response_queue: Queue = PrivateAttr(default_factory=Queue) + _stop_event: Event = PrivateAttr(default_factory=Event) + _session_thread: Optional[Thread] = PrivateAttr(default=None) + _session_exception: Optional[Exception] = PrivateAttr(default=None) + _session_finished: bool = PrivateAttr(default=False) + _stop_token: object = PrivateAttr(default_factory=object) + + _runtime_private_attr_names = { + "_cache_lock", + "_request_queue", + "_response_queue", + "_stop_event", + "_session_thread", + "_session_exception", + "_session_finished", + "_stop_token", + } + + @field_validator("method") + def validate_method(cls, v: str): + """Ensure scipy method names are not empty after whitespace normalization.""" + value = v.strip() + if value not in BOUNDED_METHODS: + raise ValueError( + f"scipy method '{value}' is not supported; choose one of {BOUNDED_METHODS}" + ) + return value + + @field_validator("initial_point") + def validate_initial_point(cls, v: Optional[Dict[str, float]]): + """Ensure that ``initial_point`` is either omitted or contains coordinates.""" + if v is not None and len(v) == 0: + raise ValueError("initial_point cannot be an empty dictionary") + return v + + def __deepcopy__(self, memo): + """Create a safe deep copy without sharing runtime thread/session objects.""" + copied = self.__class__.model_validate(self.model_dump()) + if self.data is not None: + copied.data = self.data.copy(deep=True) + copied._last_outcome = self._last_outcome + copied._cache = copied._build_cache() + return copied + + def __getstate__(self): + """Return pickle state while removing non-picklable runtime session objects.""" + # Do not pickle active threading primitives. Runtime session will be rebuilt. + self._stop_session() + state = super().__getstate__() + private = state.get("__pydantic_private__", {}) or {} + + for key in self._runtime_private_attr_names: + private.pop(key, None) + + private["_cache"] = self._build_cache() + state["__pydantic_private__"] = private + return state + + def __setstate__(self, state): + """Restore pickle state and rebuild transient runtime synchronization objects.""" + super().__setstate__(state) + self._cache_lock = Lock() + self._request_queue = Queue() + self._response_queue = Queue() + self._stop_event = Event() + self._session_thread = None + self._session_exception = None + self._session_finished = False + self._stop_token = object() + + self._cache = self._build_cache() + if self.data is not None and len(self.data) > 0: + objective_data = get_objective_data(self.vocs, self.data).to_numpy()[:, 0] + self._last_outcome = float(objective_data[-1]) + + @property + def x0(self) -> np.ndarray: + """Return the optimization start point from config or from the latest dataset row.""" + if self.initial_point is not None: + missing = [ + k for k in self.vocs.variable_names if k not in self.initial_point + ] + if missing: + raise ValueError( + f"initial_point is missing values for variables: {missing}" + ) + return np.array( + [self.initial_point[k] for k in self.vocs.variable_names], dtype=float + ) + return self._get_initial_point()[0] + + def _reset(self): + """Reset active session state while keeping existing evaluated observations.""" + self._stop_session() + self._last_outcome = None + + def _set_data(self, data: pd.DataFrame): + """Replace the full dataset and refresh cache/session-dependent internal state.""" + self._stop_session() + self.data = data + if len(data) > 0: + objective_data = get_objective_data(self.vocs, data).to_numpy()[:, 0] + self._last_outcome = float(objective_data[-1]) + self._cache = self._build_cache() + + def _add_data(self, new_data: pd.DataFrame): + """Ingest one new evaluation and optionally unblock a waiting worker objective call.""" + if len(new_data) == 0: + return + objective_data = get_objective_data(self.vocs, new_data).to_numpy()[:, 0] + self._last_outcome = float(objective_data[-1]) + + x_value = get_variable_data(self.vocs, new_data).to_numpy(dtype=float)[-1] + with self._cache_lock: + self._cache[self._point_key(x_value)] = self._last_outcome + + if self._session_thread is not None and self._session_thread.is_alive(): + self._response_queue.put(self._last_outcome) + + def _point_key(self, x: np.ndarray, decimals: int = 12) -> tuple: + """Convert a floating-point vector to a rounded hashable cache key.""" + return tuple(np.round(np.array(x, dtype=float), decimals=decimals)) + + def _build_cache(self) -> dict[tuple, float]: + """Build objective cache from ``self.data`` keyed by rounded variable vectors.""" + if self.data is None or len(self.data) == 0: + return {} + + # Build a deterministic replay table from prior Xopt observations. + x_data = get_variable_data(self.vocs, self.data).to_numpy(dtype=float) + y_data = get_objective_data(self.vocs, self.data).to_numpy()[:, 0] + + return {self._point_key(x): float(y) for x, y in zip(x_data, y_data)} + + def _map_value_error(self, ex: ValueError) -> Optional[RuntimeError]: + """Map scipy ``ValueError`` messages to clearer runtime configuration errors.""" + msg = str(ex).lower() + if "unknown solver" in msg: + return RuntimeError( + f"scipy method '{self.method}' is not available in this environment." + ) + if "cannot handle bounds" in msg: + return RuntimeError( + f"scipy method '{self.method}' does not support bounds; choose a bounded method (e.g. 'Powell', 'L-BFGS-B', 'TNC', 'SLSQP', 'trust-constr')." + ) + return None + + def _raise_session_error_if_present(self): + """Raise any worker exception in the caller thread, with friendly remapping.""" + if self._session_exception is None: + return + + ex = self._session_exception + self._session_exception = None + if isinstance(ex, ValueError): + mapped = self._map_value_error(ex) + if mapped is not None: + raise mapped from ex + raise ex + + def _objective(self, x: np.ndarray) -> float: + """Objective callback used by scipy. + + This method first serves values from cache. For uncached points, it sends + the requested point to the main thread and blocks until the corresponding + externally evaluated objective value is provided. + """ + if self._stop_event.is_set(): + raise _StopSession() + + key = self._point_key(x) + with self._cache_lock: + if key in self._cache: + return self._cache[key] + + self._request_queue.put(np.array(x, dtype=float)) + response = self._response_queue.get() + + if response is self._stop_token or self._stop_event.is_set(): + raise _StopSession() + + y_value = float(response) + with self._cache_lock: + self._cache[key] = y_value + return y_value + + def _run_session(self): + """Run one persistent ``scipy.optimize.minimize`` session in a worker thread.""" + mins, maxs = np.array(self.vocs.bounds).T + bounds = list(zip(mins, maxs)) + + minimize_kwargs = { + "method": self.method, + "bounds": bounds, + "tol": self.tol, + "options": self.options, + **self.scipy_kwargs, + } + + try: + minimize(self._objective, self.x0, **minimize_kwargs) + except _StopSession: + pass + except Exception as ex: + self._session_exception = ex + finally: + self._session_finished = True + + def _start_session_if_needed(self): + """Start the worker minimize session if one is not currently active.""" + if self._session_thread is not None and self._session_thread.is_alive(): + return + + self._stop_event.clear() + self._session_exception = None + self._session_finished = False + self._request_queue = Queue() + self._response_queue = Queue() + self._cache = self._build_cache() + + self._session_thread = Thread(target=self._run_session, daemon=True) + self._session_thread.start() + + def _stop_session(self): + """Request worker shutdown and reset transient synchronization primitives.""" + self._stop_event.set() + if self._session_thread is not None and self._session_thread.is_alive(): + self._response_queue.put(self._stop_token) + self._session_thread.join(timeout=2.0) + + self._session_thread = None + self._session_exception = None + self._session_finished = False + self._request_queue = Queue() + self._response_queue = Queue() + self._stop_event = Event() + + def _generate(self, first_gen: bool = False) -> Optional[List[Dict[str, float]]]: + """Return the next candidate requested by the live scipy session. + + The ``first_gen`` argument is accepted to satisfy the sequential generator + interface; candidate selection always follows the active persistent session. + """ + self._start_session_if_needed() + + while True: + self._raise_session_error_if_present() + + try: + requested_x = self._request_queue.get(timeout=0.05) + inputs = dict(zip(self.vocs.variable_names, requested_x.tolist())) + if self.vocs.constants is not None: + inputs.update(self.vocs.constants) + return [inputs] + except Empty: + if self._session_finished: + self._raise_session_error_if_present() + break + + # If scipy converges using only cached data, return the latest known point. + if self.data is None or len(self.data) == 0: + raise RuntimeError("scipy minimize converged without available data") + + inputs = self.data[self.vocs.variable_names].iloc[-1].to_dict() + if self.vocs.constants is not None: + inputs.update(self.vocs.constants) + return [inputs] diff --git a/xopt/tests/generators/sequential/test_scipy.py b/xopt/tests/generators/sequential/test_scipy.py new file mode 100644 index 000000000..90779b392 --- /dev/null +++ b/xopt/tests/generators/sequential/test_scipy.py @@ -0,0 +1,480 @@ +import copy +import json +from unittest.mock import patch + +import numpy as np +import pandas as pd +from pydantic import ValidationError +import pytest +from scipy.optimize import minimize + +from xopt import Xopt +from xopt.errors import SeqGeneratorError +from xopt.generators.sequential.scipy import ( + BOUNDED_METHODS, + ScipyGenerator, + _StopSession, +) +from xopt.vocs import VOCS + + +def sphere(input_dict): + return {"y": float(sum(v**2 for v in input_dict.values()))} + + +def _direct_scipy_sequence(vocs: VOCS, method: str, maxiter: int = 30): + mins, maxs = np.array(vocs.bounds).T + bounds = list(zip(mins, maxs)) + x0 = np.array([1.7, -1.3], dtype=float) + cache = {} + sequence = [] + + def objective(x): + key = tuple(np.round(np.array(x, dtype=float), decimals=12)) + if key in cache: + return cache[key] + + point = np.array(x, dtype=float) + sequence.append(point) + y_value = sphere(dict(zip(vocs.variable_names, point.tolist())))["y"] + cache[key] = y_value + return y_value + + minimize( + objective, + x0, + method=method, + bounds=bounds, + tol=1e-8, + options={"maxiter": maxiter}, + ) + + return sequence + + +class TestScipyGenerator: + def test_scipy_generate_single_point(self): + YAML = """ + generator: + name: scipy + method: Powell + initial_point: {x0: 0.5, x1: -0.5} + vocs: + variables: + x0: [-5, 5] + x1: [-5, 5] + objectives: {y: MINIMIZE} + evaluator: + function: xopt.tests.generators.sequential.test_scipy.sphere + """ + X = Xopt.from_yaml(YAML) + gen: ScipyGenerator = X.generator + + first = gen.generate(1) + assert len(first) == 1 + assert set(first[0].keys()) == {"x0", "x1"} + + # test without initial point + YAML_NO_INIT = """ + generator: + name: scipy + method: Powell + vocs: + variables: + x0: [-5, 5] + x1: [-5, 5] + objectives: {y: MINIMIZE} + evaluator: + function: xopt.tests.generators.sequential.test_scipy.sphere + """ + X_no_init = Xopt.from_yaml(YAML_NO_INIT) + X_no_init.random_evaluate( + 1 + ) # generate some data to build an initial point from + X_no_init.step() + + def test_scipy_generate_multiple_points(self): + YAML = """ + generator: + name: scipy + method: Powell + initial_point: {x0: 0.5, x1: -0.5} + vocs: + variables: + x0: [-5, 5] + x1: [-5, 5] + objectives: {y: MINIMIZE} + evaluator: + function: xopt.tests.generators.sequential.test_scipy.sphere + """ + X = Xopt.from_yaml(YAML) + with pytest.raises(SeqGeneratorError): + X.generator.generate(2) + + def test_scipy_generate_and_restart(self): + YAML = """ + generator: + name: scipy + method: Powell + initial_point: {x0: 1.2, x1: -1.1} + options: + maxiter: 200 + vocs: + variables: + x0: [-5, 5] + x1: [-5, 5] + objectives: {y: MINIMIZE} + evaluator: + function: xopt.tests.generators.sequential.test_scipy.sphere + """ + X = Xopt.from_yaml(YAML) + + for _ in range(8): + X.step() + + assert len(X.data) == 8 + + state = X.json() + X2 = Xopt.model_validate(json.loads(state)) + X2.step() + + assert len(X2.data) == 9 + + def test_scipy_generator_model_dump_roundtrip_continuation(self): + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + ) + gen = ScipyGenerator( + vocs=vocs, + method="Powell", + initial_point={"x0": 1.2, "x1": -1.1}, + tol=1e-8, + ) + + for _ in range(5): + candidate = gen.generate(1)[0] + y = sphere(candidate)["y"] + gen.add_data(pd.DataFrame([{**candidate, "y": y}])) + + restored: ScipyGenerator = ScipyGenerator.model_validate(gen.model_dump()) + restored.set_data(gen.data.copy(deep=True)) + + reference = ScipyGenerator( + vocs=vocs, + method="Powell", + initial_point={"x0": 1.2, "x1": -1.1}, + tol=1e-8, + ) + reference.set_data(gen.data.copy(deep=True)) + + restored_candidate = restored.generate(1)[0] + reference_candidate = reference.generate(1)[0] + restored_x = np.array( + [restored_candidate[name] for name in vocs.variable_names] + ) + reference_x = np.array( + [reference_candidate[name] for name in vocs.variable_names] + ) + + np.testing.assert_allclose(restored_x, reference_x, rtol=0.0, atol=1e-12) + + def test_scipy_generator_direct(self): + YAML = """ + generator: + name: scipy + method: Powell + initial_point: {x0: 1.0, x1: 1.0} + vocs: + variables: + x0: [-5, 5] + x1: [-5, 5] + objectives: {y: MINIMIZE} + evaluator: + function: xopt.tests.generators.sequential.test_scipy.sphere + """ + X = Xopt.from_yaml(YAML) + gen: ScipyGenerator = X.generator + + first = gen.generate(1) + assert len(first) == 1 + assert set(first[0].keys()) == {"x0", "x1"} + + @pytest.mark.parametrize("method", BOUNDED_METHODS) + def test_selected_points_match_scipy_minimize(self, method): + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + ) + expected_sequence = _direct_scipy_sequence(vocs, method, maxiter=30) + assert len(expected_sequence) > 0 + + gen = ScipyGenerator( + vocs=vocs, + method=method, + initial_point={"x0": 1.7, "x1": -1.3}, + tol=1e-8, + options={"maxiter": 30}, + ) + + for expected_x in expected_sequence: + candidate = gen.generate(1)[0] + actual_x = np.array([candidate[name] for name in vocs.variable_names]) + + np.testing.assert_allclose(actual_x, expected_x, rtol=0.0, atol=1e-12) + + y = sphere(candidate)["y"] + gen.add_data(pd.DataFrame([{**candidate, "y": y}])) + + def test_scipy_reset(self): + YAML = """ + generator: + name: scipy + method: Powell + initial_point: {x0: 0.75, x1: -0.25} + vocs: + variables: + x0: [-5, 5] + x1: [-5, 5] + objectives: {y: MINIMIZE} + evaluator: + function: xopt.tests.generators.sequential.test_scipy.sphere + """ + X = Xopt.from_yaml(YAML) + gen: ScipyGenerator = X.generator + + X.step() + assert gen.is_active + assert gen._last_candidate is not None + assert gen._last_outcome is not None + + gen.reset() + + assert not gen.is_active + assert gen._last_candidate is None + assert gen._last_outcome is None + + candidate = gen.generate(1) + assert len(candidate) == 1 + assert set(candidate[0].keys()) == {"x0", "x1"} + + def test_validate_method_rejects_whitespace_string(self): + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + ) + with pytest.raises( + ValueError, + match="scipy method '' is not supported; choose one of .*", + ): + ScipyGenerator(vocs=vocs, method=" ") + + def test_validate_initial_point_empty_dict(self): + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + ) + with pytest.raises( + ValueError, match="initial_point cannot be an empty dictionary" + ): + ScipyGenerator(vocs=vocs, initial_point={}) + + def test_deepcopy_with_data_copies_outcome_and_cache(self): + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + ) + gen = ScipyGenerator(vocs=vocs, method="Powell") + data = pd.DataFrame( + [{"x0": 0.2, "x1": -0.3, "y": 0.13}, {"x0": -0.1, "x1": 0.4, "y": 0.17}] + ) + gen._set_data(data) + + copied = copy.deepcopy(gen) + + assert copied is not gen + assert copied.data is not gen.data + assert copied._last_outcome == pytest.approx(gen._last_outcome) + assert copied._cache == copied._build_cache() + + def test_state_roundtrip_rebuilds_runtime_state_and_last_outcome(self): + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + ) + gen = ScipyGenerator(vocs=vocs, method="Powell") + data = pd.DataFrame( + [{"x0": 0.2, "x1": -0.3, "y": 0.13}, {"x0": -0.1, "x1": 0.4, "y": 0.17}] + ) + gen._set_data(data) + + state = gen.__getstate__() + restored = ScipyGenerator.model_validate(gen.model_dump()) + restored.__setstate__(state) + + assert restored._cache == restored._build_cache() + assert restored._last_outcome == pytest.approx(0.17) + + def test_raise_session_error_maps_unknown_solver(self): + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + ) + gen = ScipyGenerator(vocs=vocs, method="Powell") + gen._session_exception = ValueError("Unknown solver foo") + + with pytest.raises( + RuntimeError, + match="scipy method 'Powell' is not available in this environment", + ): + gen._raise_session_error_if_present() + + def test_raise_session_error_maps_cannot_handle_bounds(self): + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + ) + gen = ScipyGenerator(vocs=vocs, method="Powell") + gen._session_exception = ValueError("Method Powell cannot handle bounds") + + with pytest.raises( + RuntimeError, + match="does not support bounds", + ): + gen._raise_session_error_if_present() + + def test_objective_raises_stop_session_when_stop_event_set(self): + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + ) + gen = ScipyGenerator(vocs=vocs, method="Powell") + gen._stop_event.set() + + with pytest.raises(_StopSession): + gen._objective(np.array([0.0, 0.0])) + + def test_add_data_empty_dataframe(self): + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + ) + gen = ScipyGenerator(vocs=vocs, method="Powell") + # _add_data with empty dataframe should return early without error + gen._add_data(pd.DataFrame()) + assert gen._last_outcome is None + + def test_unknown_solver_raises_validation_error(self): + YAML = """ + generator: + name: scipy + method: NOT_A_REAL_METHOD + initial_point: {x0: 0.5, x1: -0.5} + vocs: + variables: + x0: [-5, 5] + x1: [-5, 5] + objectives: {y: MINIMIZE} + evaluator: + function: xopt.tests.generators.sequential.test_scipy.sphere + """ + with pytest.raises( + ValidationError, + match="scipy method .* is not supported; choose one of .*", + ): + Xopt.from_yaml(YAML) + + def test_non_solver_value_error_reraises(self): + """A ValueError that doesn't mention 'unknown solver' should propagate.""" + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + ) + gen = ScipyGenerator( + vocs=vocs, method="Powell", initial_point={"x0": 0.5, "x1": -0.5} + ) + + def _bad_minimize(*args, **kwargs): + raise ValueError("some other value error") + + with patch("xopt.generators.sequential.scipy.minimize", _bad_minimize): + with pytest.raises(ValueError, match="some other value error"): + gen._generate() + + def test_convergence_path_returns_last_point(self): + """When scipy converges using only cached data, the last cached point is returned.""" + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + ) + gen = ScipyGenerator( + vocs=vocs, method="Powell", initial_point={"x0": 0.5, "x1": -0.5} + ) + # Populate data so the generator has something to return + data = pd.DataFrame([{"x0": 0.1, "x1": 0.2, "y": 0.05}]) + gen._set_data(data) + + # Mock minimize to complete without calling the objective (simulates convergence) + with patch("xopt.generators.sequential.scipy.minimize", return_value=None): + result = gen._generate() + + assert result is not None + assert len(result) == 1 + assert result[0]["x0"] == pytest.approx(0.1) + assert result[0]["x1"] == pytest.approx(0.2) + + def test_convergence_path_with_constants(self): + """Convergence path includes constants in returned point.""" + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + constants={"c": 3.14}, + ) + gen = ScipyGenerator( + vocs=vocs, method="Powell", initial_point={"x0": 0.5, "x1": -0.5} + ) + data = pd.DataFrame([{"x0": 0.1, "x1": 0.2, "y": 0.05, "c": 3.14}]) + gen._set_data(data) + + with patch("xopt.generators.sequential.scipy.minimize", return_value=None): + result = gen._generate() + + assert result is not None + assert "c" in result[0] + + def test_convergence_path_without_data_raises_runtime_error(self): + """Convergence without queued requests and without data raises an error.""" + vocs = VOCS( + variables={"x0": [-5, 5], "x1": [-5, 5]}, + objectives={"y": "MINIMIZE"}, + ) + gen = ScipyGenerator( + vocs=vocs, method="Powell", initial_point={"x0": 0.5, "x1": -0.5} + ) + + with patch("xopt.generators.sequential.scipy.minimize", return_value=None): + with pytest.raises( + RuntimeError, match="scipy minimize converged without available data" + ): + gen._generate() + + def test_generate_with_constants(self): + """Constants are included in generated candidates.""" + YAML = """ + generator: + name: scipy + method: Powell + initial_point: {x0: 0.5, x1: -0.5} + vocs: + variables: + x0: [-5, 5] + x1: [-5, 5] + objectives: {y: MINIMIZE} + constants: {c: 1.0} + evaluator: + function: xopt.tests.generators.sequential.test_scipy.sphere + """ + X = Xopt.from_yaml(YAML) + candidate = X.generator.generate(1) + assert len(candidate) == 1 + assert "c" in candidate[0] diff --git a/xopt/tests/generators/sequential/test_serialization.py b/xopt/tests/generators/sequential/test_serialization.py index d33bcb288..cf6f99e62 100644 --- a/xopt/tests/generators/sequential/test_serialization.py +++ b/xopt/tests/generators/sequential/test_serialization.py @@ -1,11 +1,10 @@ -import pickle - import numpy as np import pytest from xopt.generators.sequential import ( RCDSGenerator, ExtremumSeekingGenerator, NelderMeadGenerator, + ScipyGenerator, ) from xopt import Evaluator, Xopt from xopt.vocs import VOCS @@ -20,7 +19,13 @@ def sin_function(input_dict): class TestSequentialSerialization: @pytest.mark.parametrize( - "generator", [RCDSGenerator, ExtremumSeekingGenerator, NelderMeadGenerator] + "generator", + [ + RCDSGenerator, + ExtremumSeekingGenerator, + NelderMeadGenerator, + ScipyGenerator, + ], ) def test_serialization_and_restart(self, generator): test_vocs = VOCS( @@ -42,6 +47,3 @@ def test_serialization_and_restart(self, generator): for i in range(10): X2.step() - - # test pickling - pickle.dumps(X2.generator)