From 986e86c7a8be688d32a9d12862b27d5db117ed8d Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 27 May 2026 05:20:59 -0500 Subject: [PATCH 01/27] initial implementation --- all_sequential_tests.txt | Bin 0 -> 11436 bytes docs/algorithms.md | 1 + docs/api/generators/sequential/cobyqa.md | 6 + docs/examples/sequential/cobyqa.ipynb | 419 ++++++++++++++++++ docs/index.md | 1 + mkdocs.yml | 2 + test_results.txt | Bin 0 -> 2906 bytes test_serialization_results.txt | Bin 0 -> 1224 bytes xopt/generators/__init__.py | 4 +- xopt/generators/scipy/__init__.py | 3 +- xopt/generators/sequential/__init__.py | 2 + xopt/generators/sequential/cobyqa.py | 127 ++++++ .../generators/sequential/test_cobyqa.py | 96 ++++ .../sequential/test_serialization.py | 4 +- 14 files changed, 662 insertions(+), 3 deletions(-) create mode 100644 all_sequential_tests.txt create mode 100644 docs/api/generators/sequential/cobyqa.md create mode 100644 docs/examples/sequential/cobyqa.ipynb create mode 100644 test_results.txt create mode 100644 test_serialization_results.txt create mode 100644 xopt/generators/sequential/cobyqa.py create mode 100644 xopt/tests/generators/sequential/test_cobyqa.py diff --git a/all_sequential_tests.txt b/all_sequential_tests.txt new file mode 100644 index 0000000000000000000000000000000000000000..6558b2ade80f1a0fdc03d89f6bd938ef04caab85 GIT binary patch literal 11436 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 PQ7_0B+_B|euVR#g3XQc0|tE{R?1 zig>@sGge3Ie79Jc4s?sf_a=Ksx!T%bU)Ks~6=>qx(iZx)C9Kq3Bi1dE+UPd}Uqs$f zS3P}=3TyTr9BT5+`F7PMn)hU=v>PnXIe!Kpa58okT#JVN2#*fhA^fi++6}f9+*`Up zdkvP@rdo1zH;DV-hy!XIVp+hUOJ#XuooeeZ+1SH8C&YfR|DS4hgZ&)lX6Xs`v`5C7 zK2h~IPR9-B#L5%D1Rup-;$>#8q6!8-&WVYw znFWg>F?_35kq+X%rAee%$=YZe`4=%~%cAeAWKZ3j64CP+BX!Ku_sv?z_(|=lA4N>| z!LJ{94L47`(C_d~FCopROGm!o?YDCDNG$6f_qXSQ-*b8T^!iSDn6Woz9|9?$yiQZ|Zvmqx3>9;tf7MDbL=M%#lre JUi!Z type[Generator]: elif name in all_generator_names["scipy"]: try: from xopt.generators.scipy.latin_hypercube import LatinHypercubeGenerator + from xopt.generators.sequential.cobyqa import COBYQAGenerator from xopt.generators.sequential.neldermead import NelderMeadGenerator registered_generators = [ NelderMeadGenerator, + COBYQAGenerator, LatinHypercubeGenerator, ] diff --git a/xopt/generators/scipy/__init__.py b/xopt/generators/scipy/__init__.py index 6b92bf095..0128239d9 100644 --- a/xopt/generators/scipy/__init__.py +++ b/xopt/generators/scipy/__init__.py @@ -1,4 +1,5 @@ from xopt.generators.scipy.latin_hypercube import LatinHypercubeGenerator from xopt.generators.sequential.neldermead import NelderMeadGenerator +from xopt.generators.sequential.cobyqa import COBYQAGenerator -__all__ = ["LatinHypercubeGenerator", "NelderMeadGenerator"] +__all__ = ["LatinHypercubeGenerator", "NelderMeadGenerator", "COBYQAGenerator"] diff --git a/xopt/generators/sequential/__init__.py b/xopt/generators/sequential/__init__.py index 4c3fff857..896a218b6 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.cobyqa import COBYQAGenerator __all__ = [ "SequentialGenerator", "RCDSGenerator", "ExtremumSeekingGenerator", "NelderMeadGenerator", + "COBYQAGenerator", ] diff --git a/xopt/generators/sequential/cobyqa.py b/xopt/generators/sequential/cobyqa.py new file mode 100644 index 000000000..b31e2bc21 --- /dev/null +++ b/xopt/generators/sequential/cobyqa.py @@ -0,0 +1,127 @@ +from typing import Dict, List, Optional + +import numpy as np +import pandas as pd +from pydantic import Field, 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 + + +class _RequestEvaluation(Exception): + """Internal exception used to request a new external function evaluation.""" + + def __init__(self, x: np.ndarray): + self.x = np.array(x, dtype=float) + super().__init__("COBYQA requested a new point evaluation") + + +class COBYQAGenerator(SequentialGenerator): + """ + Sequential COBYQA generator based on ``scipy.optimize.minimize``. + + This implementation follows an ask/tell pattern by replaying known evaluations from + ``self.data``. Whenever scipy requests a point that has not been evaluated yet, the + generator returns that point so that Xopt can evaluate it externally. + """ + + name = "cobyqa" + supports_single_objective: bool = True + + initial_point: Optional[Dict[str, float]] = None + tol: Optional[float] = Field( + None, description="Termination tolerance passed to scipy.optimize.minimize" + ) + options: Dict = Field( + default_factory=dict, + description="Options dictionary passed directly to scipy.optimize.minimize", + ) + + # Internal state + _last_outcome: Optional[float] = None + + @field_validator("initial_point") + def validate_initial_point(cls, v: Optional[Dict[str, float]]): + if v is None: + return v + if len(v) == 0: + raise ValueError("initial_point cannot be an empty dictionary") + return v + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + @property + def x0(self) -> np.ndarray: + if self.initial_point is not None: + return np.array([self.initial_point[k] for k in self.vocs.variable_names]) + return self._get_initial_point()[0] + + def _reset(self): + self._last_outcome = None + + def _set_data(self, data: pd.DataFrame): + 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]) + + def _add_data(self, new_data: pd.DataFrame): + 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]) + + def _point_key(self, x: np.ndarray, decimals: int = 12) -> tuple: + return tuple(np.round(np.array(x, dtype=float), decimals=decimals)) + + def _build_cache(self) -> dict[tuple, float]: + if self.data is None or len(self.data) == 0: + return {} + + 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 _generate(self, first_gen: bool = False) -> Optional[List[Dict[str, float]]]: + cache = self._build_cache() + mins, maxs = np.array(self.vocs.bounds).T + bounds = list(zip(mins, maxs)) + + def objective(x): + key = self._point_key(x) + if key in cache: + return cache[key] + raise _RequestEvaluation(x) + + try: + minimize( + objective, + self.x0, + method="cobyqa", + bounds=bounds, + tol=self.tol, + options=self.options, + ) + except _RequestEvaluation as req: + inputs = dict(zip(self.vocs.variable_names, req.x.tolist())) + if self.vocs.constants is not None: + inputs.update(self.vocs.constants) + return [inputs] + except ValueError as ex: + msg = str(ex).lower() + if "unknown solver" in msg and "cobyqa" in msg: + raise RuntimeError( + "scipy COBYQA is not available in this environment. " + "Install a scipy version that supports minimize(method='cobyqa')." + ) from ex + raise + + # If scipy converges using only cached data, return the latest known point. + last_x = self.x0 + inputs = dict(zip(self.vocs.variable_names, np.array(last_x).tolist())) + if self.vocs.constants is not None: + inputs.update(self.vocs.constants) + return [inputs] diff --git a/xopt/tests/generators/sequential/test_cobyqa.py b/xopt/tests/generators/sequential/test_cobyqa.py new file mode 100644 index 000000000..1b9cf278a --- /dev/null +++ b/xopt/tests/generators/sequential/test_cobyqa.py @@ -0,0 +1,96 @@ +import json + +import numpy as np +import pytest +from scipy.optimize import minimize + +from xopt import Xopt +from xopt.errors import SeqGeneratorError +from xopt.generators.sequential.cobyqa import COBYQAGenerator + + +def _has_scipy_cobyqa() -> bool: + def objective(x): + return float(np.sum(np.array(x) ** 2)) + + try: + minimize(objective, [1.0], method="cobyqa", bounds=[(-2.0, 2.0)]) + except ValueError: + return False + return True + + +pytestmark = pytest.mark.skipif( + not _has_scipy_cobyqa(), reason="scipy COBYQA is not available in this environment" +) + + +def sphere(input_dict): + return {"y": float(sum(v**2 for v in input_dict.values()))} + + +class TestCOBYQAGenerator: + def test_cobyqa_generate_multiple_points(self): + YAML = """ + generator: + name: cobyqa + 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_cobyqa.sphere + """ + X = Xopt.from_yaml(YAML) + with pytest.raises(SeqGeneratorError): + X.generator.generate(2) + + def test_cobyqa_generate_and_restart(self): + YAML = """ + generator: + name: cobyqa + 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_cobyqa.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_cobyqa_generator_direct(self): + YAML = """ + generator: + name: cobyqa + 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_cobyqa.sphere + """ + X = Xopt.from_yaml(YAML) + gen: COBYQAGenerator = X.generator + + first = gen.generate(1) + assert len(first) == 1 + assert set(first[0].keys()) == {"x0", "x1"} diff --git a/xopt/tests/generators/sequential/test_serialization.py b/xopt/tests/generators/sequential/test_serialization.py index d33bcb288..955b963de 100644 --- a/xopt/tests/generators/sequential/test_serialization.py +++ b/xopt/tests/generators/sequential/test_serialization.py @@ -6,6 +6,7 @@ RCDSGenerator, ExtremumSeekingGenerator, NelderMeadGenerator, + COBYQAGenerator, ) from xopt import Evaluator, Xopt from xopt.vocs import VOCS @@ -20,7 +21,8 @@ def sin_function(input_dict): class TestSequentialSerialization: @pytest.mark.parametrize( - "generator", [RCDSGenerator, ExtremumSeekingGenerator, NelderMeadGenerator] + "generator", + [RCDSGenerator, ExtremumSeekingGenerator, NelderMeadGenerator, COBYQAGenerator], ) def test_serialization_and_restart(self, generator): test_vocs = VOCS( From 68ab06bf8c73c548c6eef9bc67a4207f461b8dd5 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 27 May 2026 14:07:07 -0700 Subject: [PATCH 02/27] generalize bobyqa to any scipy minimize method --- docs/algorithms.md | 2 +- docs/api/generators/sequential/cobyqa.md | 6 - docs/examples/sequential/cobyqa.ipynb | 419 ------------------ docs/index.md | 2 +- mkdocs.yml | 2 - xopt/generators/__init__.py | 6 +- xopt/generators/scipy/__init__.py | 8 +- xopt/generators/sequential/__init__.py | 4 +- .../sequential/{cobyqa.py => scipy.py} | 57 ++- .../{test_cobyqa.py => test_scipy.py} | 45 +- .../sequential/test_serialization.py | 9 +- 11 files changed, 69 insertions(+), 491 deletions(-) delete mode 100644 docs/api/generators/sequential/cobyqa.md delete mode 100644 docs/examples/sequential/cobyqa.ipynb rename xopt/generators/sequential/{cobyqa.py => scipy.py} (72%) rename xopt/tests/generators/sequential/{test_cobyqa.py => test_scipy.py} (60%) diff --git a/docs/algorithms.md b/docs/algorithms.md index 62232d589..625c12f2b 100644 --- a/docs/algorithms.md +++ b/docs/algorithms.md @@ -65,7 +65,7 @@ Scipy Generators These generators serve as wrappers for algorithms implemented in scipy. - [`NelderMeadGenerator`](examples/sequential/neldermead.ipynb): implements Nelder-Mead (simplex) optimization. -- [`COBYQAGenerator`](examples/sequential/cobyqa.ipynb): implements COBYQA (Constrained Optimization BY Quadratic Approximation) trust-region optimization. +- [`ScipyGenerator`](examples/sequential/neldermead.ipynb): generic wrapper around `scipy.optimize.minimize` methods. - [`LatinHypercubeGenerator`](examples/scipy/latin_hypercube.ipynb): perform latin hypercube sampling of the evaluation function. RCDS Generators diff --git a/docs/api/generators/sequential/cobyqa.md b/docs/api/generators/sequential/cobyqa.md deleted file mode 100644 index 22d15e07d..000000000 --- a/docs/api/generators/sequential/cobyqa.md +++ /dev/null @@ -1,6 +0,0 @@ -# COBYQA Generator - -::: xopt.generators.sequential.cobyqa - handler: python - options: - show_source: true diff --git a/docs/examples/sequential/cobyqa.ipynb b/docs/examples/sequential/cobyqa.ipynb deleted file mode 100644 index a0e52763e..000000000 --- a/docs/examples/sequential/cobyqa.ipynb +++ /dev/null @@ -1,419 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "f27f2ab8", - "metadata": {}, - "source": [ - "# COBYQA Sequential Optimization\n", - "\n", - "This notebook demonstrates how to use the COBYQA (Constrained Optimization BY Quadratic Approximation) algorithm in Xopt for sequential derivative-free optimization." - ] - }, - { - "cell_type": "markdown", - "id": "e23f8c10", - "metadata": {}, - "source": [ - "## Overview\n", - "\n", - "COBYQA is a trust-region derivative-free optimization method that:\n", - "- Uses local quadratic models to guide the search\n", - "- Automatically handles variable bounds\n", - "- Works well for expensive black-box function evaluations\n", - "- Suitable for single-objective optimization problems\n", - "\n", - "In Xopt, COBYQAGenerator implements the ask/tell pattern, allowing you to:\n", - "1. Request the next candidate point to evaluate\n", - "2. Evaluate it externally (e.g., run a simulation)\n", - "3. Provide the result back to the optimizer\n", - "4. Repeat until convergence" - ] - }, - { - "cell_type": "markdown", - "id": "f3f0f45c", - "metadata": {}, - "source": [ - "## Setup" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "5f5cdf16", - "metadata": {}, - "outputs": [], - "source": [ - "import math\n", - "import numpy as np\n", - "import pandas as pd\n", - "from xopt import Xopt\n", - "from xopt.vocs import VOCS\n", - "from xopt.evaluator import Evaluator\n", - "from xopt.generators.sequential.cobyqa import COBYQAGenerator" - ] - }, - { - "cell_type": "markdown", - "id": "06ca3d3b", - "metadata": {}, - "source": [ - "## Example 1: Simple 2D Rosenbrock Function" - ] - }, - { - "cell_type": "markdown", - "id": "5524e4ab", - "metadata": {}, - "source": [ - "Let's optimize the Rosenbrock function, a classic test case for optimization algorithms." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "3fc79be1", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "f(0, 0) = 1.00\n", - "f(1, 1) = 0.00\n" - ] - } - ], - "source": [ - "def rosenbrock(input_dict):\n", - " \"\"\"Rosenbrock function: f(x,y) = (1-x)^2 + 100(y-x^2)^2\"\"\"\n", - " x = input_dict[\"x\"]\n", - " y = input_dict[\"y\"]\n", - " return {\"f\": (1 - x) ** 2 + 100 * (y - x ** 2) ** 2}\n", - "\n", - "# Test the function\n", - "print(f\"f(0, 0) = {rosenbrock({'x': 0, 'y': 0})['f']:.2f}\")\n", - "print(f\"f(1, 1) = {rosenbrock({'x': 1, 'y': 1})['f']:.2f}\")" - ] - }, - { - "cell_type": "markdown", - "id": "c58cb766", - "metadata": {}, - "source": [ - "### Define the optimization problem" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "d7806662", - "metadata": {}, - "outputs": [], - "source": [ - "# Define variables and objective\n", - "vocs = VOCS(\n", - " variables={\"x\": [-2, 2], \"y\": [-1, 3]},\n", - " objectives={\"f\": \"MINIMIZE\"},\n", - ")\n", - "\n", - "# Create evaluator\n", - "evaluator = Evaluator(function=rosenbrock)\n", - "\n", - "# Create COBYQA generator with initial point\n", - "generator = COBYQAGenerator(\n", - " vocs=vocs,\n", - " initial_point={\"x\": 0.0, \"y\": 0.0},\n", - " options={\"maxiter\": 100}, # scipy COBYQA options\n", - ")\n", - "\n", - "# Create Xopt object\n", - "X = Xopt(evaluator=evaluator, generator=generator)" - ] - }, - { - "cell_type": "markdown", - "id": "960f42ad", - "metadata": {}, - "source": [ - "### Run optimization" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "69eb81fb", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Step 5: f = 1.000000\n", - "Step 10: f = 0.703637\n", - "Step 15: f = 0.532265\n", - "Step 20: f = 0.390269\n", - "Step 25: f = 0.239811\n", - "Step 30: f = 0.181896\n", - "Step 35: f = 0.103104\n", - "Step 40: f = 0.101353\n", - "Step 45: f = 0.082039\n", - "Step 50: f = 0.049387\n", - "\n", - "Optimization complete!\n" - ] - } - ], - "source": [ - "# Run optimization for 50 steps\n", - "for i in range(50):\n", - " X.step()\n", - " if (i + 1) % 5 == 0:\n", - " best_idx = X.data[\"f\"].argmin()\n", - " best_f = X.data[\"f\"].iloc[best_idx]\n", - " print(f\"Step {i + 1:2d}: f = {best_f:.6f}\")\n", - "\n", - "print(\"\\nOptimization complete!\")" - ] - }, - { - "cell_type": "markdown", - "id": "1155b1c3", - "metadata": {}, - "source": [ - "### View results" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "25a89231", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Number of evaluations: 20\n", - "\n", - "Best result:\n", - " x = 0.386789\n", - " y = 0.137672\n", - " f = 0.390269\n", - "\n", - "Optimal point: (1, 1) with f = 0\n" - ] - } - ], - "source": [ - "# Show final result\n", - "best_idx = X.data[\"f\"].argmin()\n", - "best_point = X.data.iloc[best_idx]\n", - "\n", - "print(f\"Number of evaluations: {len(X.data)}\")\n", - "print(f\"\\nBest result:\")\n", - "print(f\" x = {best_point['x']:.6f}\")\n", - "print(f\" y = {best_point['y']:.6f}\")\n", - "print(f\" f = {best_point['f']:.6f}\")\n", - "print(f\"\\nOptimal point: (1, 1) with f = 0\")" - ] - }, - { - "cell_type": "markdown", - "id": "d5894d78", - "metadata": {}, - "source": [ - "## Example 2: Using YAML Configuration" - ] - }, - { - "cell_type": "markdown", - "id": "6b304af3", - "metadata": {}, - "source": [ - "You can also configure COBYQA using YAML strings for easier serialization and reproducibility." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "b86747c5", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "YAML configuration ready (see above)\n" - ] - } - ], - "source": [ - "yaml_config = \"\"\"\n", - "generator:\n", - " name: cobyqa\n", - " initial_point: {x: -1.5, y: 2.5}\n", - " options:\n", - " maxiter: 150\n", - " vocs:\n", - " variables:\n", - " x: [-2, 2]\n", - " y: [-1, 3]\n", - " objectives:\n", - " f: MINIMIZE\n", - "\n", - "evaluator:\n", - " function: __main__.rosenbrock\n", - "\"\"\"\n", - "\n", - "# Note: In a real notebook, you'd use the actual module path\n", - "# For this example, we'll use the direct Python approach above instead\n", - "print(\"YAML configuration ready (see above)\")" - ] - }, - { - "cell_type": "markdown", - "id": "d45ccba6", - "metadata": {}, - "source": [ - "## Example 3: Sphere Function (Simpler Case)" - ] - }, - { - "cell_type": "markdown", - "id": "2ef87c26", - "metadata": {}, - "source": [ - "Let's also try a simpler sphere function to see faster convergence." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "c64c26bd", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "3D Sphere Optimization Results:\n", - " Evaluations: 15\n", - " Best x = 0.117773\n", - " Best y = 0.176660\n", - " Best z = 0.176660\n", - " Best f = 0.076288\n" - ] - } - ], - "source": [ - "def sphere_3d(input_dict):\n", - " \"\"\"Sphere function in 3D: f(x,y,z) = x^2 + y^2 + z^2\"\"\"\n", - " x = input_dict[\"x\"]\n", - " y = input_dict[\"y\"]\n", - " z = input_dict[\"z\"]\n", - " return {\"f\": x**2 + y**2 + z**2}\n", - "\n", - "# Define the problem\n", - "vocs_3d = VOCS(\n", - " variables={\n", - " \"x\": [-5, 5],\n", - " \"y\": [-5, 5],\n", - " \"z\": [-5, 5],\n", - " },\n", - " objectives={\"f\": \"MINIMIZE\"},\n", - ")\n", - "\n", - "# Create Xopt with COBYQA\n", - "evaluator_3d = Evaluator(function=sphere_3d)\n", - "generator_3d = COBYQAGenerator(\n", - " vocs=vocs_3d,\n", - " initial_point={\"x\": 3.0, \"y\": 3.0, \"z\": 3.0},\n", - ")\n", - "\n", - "X_3d = Xopt(evaluator=evaluator_3d, generator=generator_3d)\n", - "\n", - "# Run optimization\n", - "for i in range(15):\n", - " X_3d.step()\n", - "\n", - "# Show results\n", - "best_idx = X_3d.data[\"f\"].argmin()\n", - "best_point_3d = X_3d.data.iloc[best_idx]\n", - "\n", - "print(f\"3D Sphere Optimization Results:\")\n", - "print(f\" Evaluations: {len(X_3d.data)}\")\n", - "print(f\" Best x = {best_point_3d['x']:.6f}\")\n", - "print(f\" Best y = {best_point_3d['y']:.6f}\")\n", - "print(f\" Best z = {best_point_3d['z']:.6f}\")\n", - "print(f\" Best f = {best_point_3d['f']:.6f}\")" - ] - }, - { - "cell_type": "markdown", - "id": "d7ec10c2", - "metadata": {}, - "source": [ - "## Key Parameters\n", - "\n", - "- **`initial_point`**: Starting point for the optimization (required for COBYQAGenerator)\n", - "- **`tol`**: Termination tolerance (passed to scipy.optimize.minimize)\n", - "- **`options`**: Dictionary of scipy COBYQA options:\n", - " - `maxiter`: Maximum number of iterations\n", - " - `rhobeg`: Initial trust region radius\n", - " - `rhoend`: Final trust region radius\n", - " - Other scipy COBYQA options as documented in scipy" - ] - }, - { - "cell_type": "markdown", - "id": "fa768885", - "metadata": {}, - "source": [ - "## Advantages of COBYQA\n", - "\n", - "1. **Derivative-free**: No need for gradient information\n", - "2. **Trust-region based**: Efficient use of function evaluations\n", - "3. **Bounds handling**: Native support for variable bounds\n", - "4. **Quadratic models**: Uses quadratic approximations for guidance\n", - "5. **Ask/tell pattern**: Works naturally with external evaluators" - ] - }, - { - "cell_type": "markdown", - "id": "c6219f61", - "metadata": {}, - "source": [ - "## When to Use COBYQA\n", - "\n", - "- Single-objective optimization problems\n", - "- Expensive function evaluations (simulation, experiment, etc.)\n", - "- No gradient information available\n", - "- Moderate dimensionality (typically < 100 variables)\n", - "- Deterministic or low-noise evaluation functions" - ] - } - ], - "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/docs/index.md b/docs/index.md index 97902d6d4..5b61be138 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,7 +41,7 @@ Currenty **Xopt** provides: - `extremum_seeking` Extremum seeking time-dependent optimization - `rcds` Robust Conjugate Direction Search (RCDS) - `neldermead` Nelder-Mead Simplex - - `cobyqa` COBYQA trust-region derivative-free optimization + - `scipy` Generic scipy.optimize.minimize sequential optimization - Sampling algorithms: - `random` Uniform random sampling - Convenient YAML/JSON based input format diff --git a/mkdocs.yml b/mkdocs.yml index d4a0ae163..ab8a96eca 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,7 +62,6 @@ nav: - Sequential: - Nelder-Mead: examples/sequential/neldermead.ipynb - - COBYQA: examples/sequential/cobyqa.ipynb - Extremum seeking: examples/sequential/extremum_seeking.ipynb - RCDS: examples/sequential/rcds.ipynb - Other: @@ -107,7 +106,6 @@ nav: - RCDS: api/generators/sequential/rcds.md - Extremum Seeking: api/generators/sequential/extremumseeking.md - Nelder-Mead: api/generators/sequential/neldermead.md - - COBYQA: api/generators/sequential/cobyqa.md - Random Generator: api/generators/random.md - Deduplicated Base: api/generators/deduplicated.md - Generator Utilities: api/generators/utils.md diff --git a/xopt/generators/__init__.py b/xopt/generators/__init__.py index e616779d4..986f6798a 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", "cobyqa"}, + "scipy": {"neldermead", "latin_hypercube", "scipy"}, "bo": { "upper_confidence_bound", "mobo", @@ -59,12 +59,12 @@ def get_generator_dynamic(name: str) -> type[Generator]: elif name in all_generator_names["scipy"]: try: from xopt.generators.scipy.latin_hypercube import LatinHypercubeGenerator - from xopt.generators.sequential.cobyqa import COBYQAGenerator from xopt.generators.sequential.neldermead import NelderMeadGenerator + from xopt.generators.sequential.scipy import ScipyGenerator registered_generators = [ NelderMeadGenerator, - COBYQAGenerator, + ScipyGenerator, LatinHypercubeGenerator, ] diff --git a/xopt/generators/scipy/__init__.py b/xopt/generators/scipy/__init__.py index 0128239d9..3540a532e 100644 --- a/xopt/generators/scipy/__init__.py +++ b/xopt/generators/scipy/__init__.py @@ -1,5 +1,9 @@ from xopt.generators.scipy.latin_hypercube import LatinHypercubeGenerator from xopt.generators.sequential.neldermead import NelderMeadGenerator -from xopt.generators.sequential.cobyqa import COBYQAGenerator +from xopt.generators.sequential.scipy import ScipyGenerator -__all__ = ["LatinHypercubeGenerator", "NelderMeadGenerator", "COBYQAGenerator"] +__all__ = [ + "LatinHypercubeGenerator", + "NelderMeadGenerator", + "ScipyGenerator", +] diff --git a/xopt/generators/sequential/__init__.py b/xopt/generators/sequential/__init__.py index 896a218b6..9a9bf6310 100644 --- a/xopt/generators/sequential/__init__.py +++ b/xopt/generators/sequential/__init__.py @@ -2,12 +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.cobyqa import COBYQAGenerator +from xopt.generators.sequential.scipy import ScipyGenerator __all__ = [ "SequentialGenerator", "RCDSGenerator", "ExtremumSeekingGenerator", "NelderMeadGenerator", - "COBYQAGenerator", + "ScipyGenerator", ] diff --git a/xopt/generators/sequential/cobyqa.py b/xopt/generators/sequential/scipy.py similarity index 72% rename from xopt/generators/sequential/cobyqa.py rename to xopt/generators/sequential/scipy.py index b31e2bc21..3c5e69350 100644 --- a/xopt/generators/sequential/cobyqa.py +++ b/xopt/generators/sequential/scipy.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional import numpy as np import pandas as pd @@ -14,33 +14,48 @@ class _RequestEvaluation(Exception): def __init__(self, x: np.ndarray): self.x = np.array(x, dtype=float) - super().__init__("COBYQA requested a new point evaluation") + super().__init__("scipy.optimize.minimize requested a new point evaluation") -class COBYQAGenerator(SequentialGenerator): +class ScipyGenerator(SequentialGenerator): """ - Sequential COBYQA generator based on ``scipy.optimize.minimize``. + Sequential scipy generator based on ``scipy.optimize.minimize``. This implementation follows an ask/tell pattern by replaying known evaluations from ``self.data``. Whenever scipy requests a point that has not been evaluated yet, the generator returns that point so that Xopt can evaluate it externally. """ - name = "cobyqa" + 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( - None, description="Termination tolerance passed to scipy.optimize.minimize" + 1e-8, description="Termination tolerance passed to scipy.optimize.minimize" ) - options: Dict = Field( + 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 + @field_validator("method") + def validate_method(cls, v: str): + value = v.strip() + if not value: + raise ValueError("method cannot be empty") + return value + @field_validator("initial_point") def validate_initial_point(cls, v: Optional[Dict[str, float]]): if v is None: @@ -49,9 +64,6 @@ def validate_initial_point(cls, v: Optional[Dict[str, float]]): raise ValueError("initial_point cannot be an empty dictionary") return v - def __init__(self, **kwargs): - super().__init__(**kwargs) - @property def x0(self) -> np.ndarray: if self.initial_point is not None: @@ -96,15 +108,16 @@ def objective(x): return cache[key] raise _RequestEvaluation(x) + minimize_kwargs = { + "method": self.method, + "bounds": bounds, + "tol": self.tol, + "options": self.options, + **self.scipy_kwargs, + } + try: - minimize( - objective, - self.x0, - method="cobyqa", - bounds=bounds, - tol=self.tol, - options=self.options, - ) + minimize(objective, self.x0, **minimize_kwargs) except _RequestEvaluation as req: inputs = dict(zip(self.vocs.variable_names, req.x.tolist())) if self.vocs.constants is not None: @@ -112,16 +125,14 @@ def objective(x): return [inputs] except ValueError as ex: msg = str(ex).lower() - if "unknown solver" in msg and "cobyqa" in msg: + if "unknown solver" in msg: raise RuntimeError( - "scipy COBYQA is not available in this environment. " - "Install a scipy version that supports minimize(method='cobyqa')." + f"scipy method '{self.method}' is not available in this environment." ) from ex raise # If scipy converges using only cached data, return the latest known point. - last_x = self.x0 - inputs = dict(zip(self.vocs.variable_names, np.array(last_x).tolist())) + 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_cobyqa.py b/xopt/tests/generators/sequential/test_scipy.py similarity index 60% rename from xopt/tests/generators/sequential/test_cobyqa.py rename to xopt/tests/generators/sequential/test_scipy.py index 1b9cf278a..04e534dbf 100644 --- a/xopt/tests/generators/sequential/test_cobyqa.py +++ b/xopt/tests/generators/sequential/test_scipy.py @@ -1,39 +1,22 @@ import json -import numpy as np import pytest -from scipy.optimize import minimize from xopt import Xopt from xopt.errors import SeqGeneratorError -from xopt.generators.sequential.cobyqa import COBYQAGenerator - - -def _has_scipy_cobyqa() -> bool: - def objective(x): - return float(np.sum(np.array(x) ** 2)) - - try: - minimize(objective, [1.0], method="cobyqa", bounds=[(-2.0, 2.0)]) - except ValueError: - return False - return True - - -pytestmark = pytest.mark.skipif( - not _has_scipy_cobyqa(), reason="scipy COBYQA is not available in this environment" -) +from xopt.generators.sequential.scipy import ScipyGenerator def sphere(input_dict): return {"y": float(sum(v**2 for v in input_dict.values()))} -class TestCOBYQAGenerator: - def test_cobyqa_generate_multiple_points(self): +class TestScipyGenerator: + def test_scipy_generate_multiple_points(self): YAML = """ generator: - name: cobyqa + name: scipy + method: Powell initial_point: {x0: 0.5, x1: -0.5} vocs: variables: @@ -41,16 +24,17 @@ def test_cobyqa_generate_multiple_points(self): x1: [-5, 5] objectives: {y: MINIMIZE} evaluator: - function: xopt.tests.generators.sequential.test_cobyqa.sphere + function: xopt.tests.generators.sequential.test_scipy.sphere """ X = Xopt.from_yaml(YAML) with pytest.raises(SeqGeneratorError): X.generator.generate(2) - def test_cobyqa_generate_and_restart(self): + def test_scipy_generate_and_restart(self): YAML = """ generator: - name: cobyqa + name: scipy + method: Powell initial_point: {x0: 1.2, x1: -1.1} options: maxiter: 200 @@ -60,7 +44,7 @@ def test_cobyqa_generate_and_restart(self): x1: [-5, 5] objectives: {y: MINIMIZE} evaluator: - function: xopt.tests.generators.sequential.test_cobyqa.sphere + function: xopt.tests.generators.sequential.test_scipy.sphere """ X = Xopt.from_yaml(YAML) @@ -75,10 +59,11 @@ def test_cobyqa_generate_and_restart(self): assert len(X2.data) == 9 - def test_cobyqa_generator_direct(self): + def test_scipy_generator_direct(self): YAML = """ generator: - name: cobyqa + name: scipy + method: Powell initial_point: {x0: 1.0, x1: 1.0} vocs: variables: @@ -86,10 +71,10 @@ def test_cobyqa_generator_direct(self): x1: [-5, 5] objectives: {y: MINIMIZE} evaluator: - function: xopt.tests.generators.sequential.test_cobyqa.sphere + function: xopt.tests.generators.sequential.test_scipy.sphere """ X = Xopt.from_yaml(YAML) - gen: COBYQAGenerator = X.generator + gen: ScipyGenerator = X.generator first = gen.generate(1) assert len(first) == 1 diff --git a/xopt/tests/generators/sequential/test_serialization.py b/xopt/tests/generators/sequential/test_serialization.py index 955b963de..0ea972d68 100644 --- a/xopt/tests/generators/sequential/test_serialization.py +++ b/xopt/tests/generators/sequential/test_serialization.py @@ -6,7 +6,7 @@ RCDSGenerator, ExtremumSeekingGenerator, NelderMeadGenerator, - COBYQAGenerator, + ScipyGenerator, ) from xopt import Evaluator, Xopt from xopt.vocs import VOCS @@ -22,7 +22,12 @@ def sin_function(input_dict): class TestSequentialSerialization: @pytest.mark.parametrize( "generator", - [RCDSGenerator, ExtremumSeekingGenerator, NelderMeadGenerator, COBYQAGenerator], + [ + RCDSGenerator, + ExtremumSeekingGenerator, + NelderMeadGenerator, + ScipyGenerator, + ], ) def test_serialization_and_restart(self, generator): test_vocs = VOCS( From caeb5f29526f50ec554136c35cbb519d1e912894 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 27 May 2026 14:15:41 -0700 Subject: [PATCH 03/27] move latin_hypercube --- xopt/generators/__init__.py | 2 +- xopt/generators/{scipy => }/latin_hypercube.py | 0 xopt/generators/scipy/__init__.py | 9 --------- xopt/tests/generators/test_latin_hypercube.py | 2 +- 4 files changed, 2 insertions(+), 11 deletions(-) rename xopt/generators/{scipy => }/latin_hypercube.py (100%) delete mode 100644 xopt/generators/scipy/__init__.py diff --git a/xopt/generators/__init__.py b/xopt/generators/__init__.py index 986f6798a..832ed54b4 100644 --- a/xopt/generators/__init__.py +++ b/xopt/generators/__init__.py @@ -58,7 +58,7 @@ def get_generator_dynamic(name: str) -> type[Generator]: ) elif name in all_generator_names["scipy"]: try: - from xopt.generators.scipy.latin_hypercube import LatinHypercubeGenerator + from xopt.generators.latin_hypercube import LatinHypercubeGenerator from xopt.generators.sequential.neldermead import NelderMeadGenerator from xopt.generators.sequential.scipy import ScipyGenerator diff --git a/xopt/generators/scipy/latin_hypercube.py b/xopt/generators/latin_hypercube.py similarity index 100% rename from xopt/generators/scipy/latin_hypercube.py rename to xopt/generators/latin_hypercube.py diff --git a/xopt/generators/scipy/__init__.py b/xopt/generators/scipy/__init__.py deleted file mode 100644 index 3540a532e..000000000 --- a/xopt/generators/scipy/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from xopt.generators.scipy.latin_hypercube import LatinHypercubeGenerator -from xopt.generators.sequential.neldermead import NelderMeadGenerator -from xopt.generators.sequential.scipy import ScipyGenerator - -__all__ = [ - "LatinHypercubeGenerator", - "NelderMeadGenerator", - "ScipyGenerator", -] diff --git a/xopt/tests/generators/test_latin_hypercube.py b/xopt/tests/generators/test_latin_hypercube.py index 787f2f4ba..0757a2328 100644 --- a/xopt/tests/generators/test_latin_hypercube.py +++ b/xopt/tests/generators/test_latin_hypercube.py @@ -7,7 +7,7 @@ from gest_api.vocs import ContinuousVariable from xopt.errors import VOCSError -from xopt.generators.scipy.latin_hypercube import LatinHypercubeGenerator +from xopt.generators.latin_hypercube import LatinHypercubeGenerator from xopt.resources.testing import TEST_VOCS_BASE From f85cf219a97f7051790db03e8fbbc7b5038fe99e Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 27 May 2026 14:15:52 -0700 Subject: [PATCH 04/27] update scipy docs --- xopt/generators/sequential/scipy.py | 33 +++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/xopt/generators/sequential/scipy.py b/xopt/generators/sequential/scipy.py index 3c5e69350..8b2bd4af4 100644 --- a/xopt/generators/sequential/scipy.py +++ b/xopt/generators/sequential/scipy.py @@ -19,11 +19,34 @@ def __init__(self, x: np.ndarray): class ScipyGenerator(SequentialGenerator): """ - Sequential scipy generator based on ``scipy.optimize.minimize``. + 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 replaying previously-evaluated points from ``self.data``: + + 1. Build a cache from existing Xopt observations. + 2. Run ``scipy.optimize.minimize`` with an objective function that first checks + the cache. + 3. If scipy asks for a point not in cache, raise ``_RequestEvaluation`` to exit + minimize early and return that point to Xopt. + 4. Xopt evaluates the point externally and appends it to ``self.data``. + 5. The next ``step`` repeats the process with the larger cache. + + Performance implications + ------------------------ + - ``_build_cache`` is O(N) in number of past evaluations and is executed every + ``step``. This overhead is typically small when objective evaluations are + expensive, but can dominate for very cheap objectives. + - ``minimize`` is restarted from ``x0`` each ``step`` and progresses by replaying + cached points. This is robust and method-agnostic, but adds repeated optimizer + bookkeeping work compared to a persistent in-memory scipy run. + - Point keys are rounded (12 decimals) before cache lookup to avoid fragile + floating-point equality checks. This improves replay stability across methods + that revisit numerically-close points. - This implementation follows an ask/tell pattern by replaying known evaluations from - ``self.data``. Whenever scipy requests a point that has not been evaluated yet, the - generator returns that point so that Xopt can evaluate it externally. """ name = "scipy" @@ -92,6 +115,7 @@ def _build_cache(self) -> dict[tuple, float]: 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] @@ -106,6 +130,7 @@ def objective(x): key = self._point_key(x) if key in cache: return cache[key] + # Exit scipy early when a new point is requested so Xopt can evaluate it. raise _RequestEvaluation(x) minimize_kwargs = { From bbab28e5a30710b7b017a6ee19425f9c6c408445 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 27 May 2026 14:33:04 -0700 Subject: [PATCH 05/27] update tests and docs --- docs/algorithms.md | 2 +- docs/api/generators/sequential/scipy.md | 31 +++ docs/examples/sequential/scipy.ipynb | 192 ++++++++++++++++++ mkdocs.yml | 2 + xopt/generators/sequential/scipy.py | 54 ++--- .../tests/generators/sequential/test_scipy.py | 38 ++++ 6 files changed, 291 insertions(+), 28 deletions(-) create mode 100644 docs/api/generators/sequential/scipy.md create mode 100644 docs/examples/sequential/scipy.ipynb diff --git a/docs/algorithms.md b/docs/algorithms.md index 625c12f2b..75f4558a5 100644 --- a/docs/algorithms.md +++ b/docs/algorithms.md @@ -65,7 +65,7 @@ Scipy Generators These generators serve as wrappers for algorithms implemented in scipy. - [`NelderMeadGenerator`](examples/sequential/neldermead.ipynb): implements Nelder-Mead (simplex) optimization. -- [`ScipyGenerator`](examples/sequential/neldermead.ipynb): generic wrapper around `scipy.optimize.minimize` methods. +- [`ScipyGenerator`](examples/sequential/scipy.ipynb): generic wrapper around `scipy.optimize.minimize` methods. - [`LatinHypercubeGenerator`](examples/scipy/latin_hypercube.ipynb): perform 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..23625b112 --- /dev/null +++ b/docs/api/generators/sequential/scipy.md @@ -0,0 +1,31 @@ +# 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 replaying known evaluations: + +1. A cache is built from `X.data`. +2. `minimize` is called with an objective wrapper that checks the cache first. +3. If scipy asks for an unseen point, the generator raises an internal signal, exits `minimize`, and returns that point to Xopt. +4. Xopt evaluates that point and appends the result. +5. On the next `step`, `minimize` is called again with the larger cache. + +## Performance Notes + +- Cache reconstruction is O(N) per `step`, where N is the number of collected evaluations. +- `minimize` restarts each `step`, so there is repeated optimizer bookkeeping overhead. +- For expensive evaluations, this overhead is usually negligible. +- For cheap synthetic test functions, this overhead can be a significant part of runtime. + +## 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/sequential/scipy.ipynb b/docs/examples/sequential/scipy.ipynb new file mode 100644 index 000000000..08c3a6a7e --- /dev/null +++ b/docs/examples/sequential/scipy.ipynb @@ -0,0 +1,192 @@ +{ + "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": 1, + "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": 2, + "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 `Powell`, but methods such as `Nelder-Mead`, `L-BFGS-B`, and others can also be used." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "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": 4, + "id": "775e0fe9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Evaluations: 200\n", + "Best point: {'x0': 0.9999969941651881, 'x1': 0.9999939646341647}\n", + "Best objective: 9.091236787525807e-12\n" + ] + } + ], + "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": 5, + "id": "a5518499", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "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()" + ] + }, + { + "cell_type": "markdown", + "id": "f693d7e1", + "metadata": {}, + "source": [ + "## Notes on performance\n", + "\n", + "`ScipyGenerator` bridges scipy's in-process `minimize` API into Xopt's external evaluation loop by replaying cached points each step. This adds overhead that is small for expensive evaluations but can be noticeable for very fast toy objectives." + ] + } + ], + "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 ab8a96eca..e3695940b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -61,6 +61,7 @@ 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 @@ -103,6 +104,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/sequential/scipy.py b/xopt/generators/sequential/scipy.py index 8b2bd4af4..967a4dbec 100644 --- a/xopt/generators/sequential/scipy.py +++ b/xopt/generators/sequential/scipy.py @@ -19,33 +19,33 @@ def __init__(self, x: np.ndarray): 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 replaying previously-evaluated points from ``self.data``: - - 1. Build a cache from existing Xopt observations. - 2. Run ``scipy.optimize.minimize`` with an objective function that first checks - the cache. - 3. If scipy asks for a point not in cache, raise ``_RequestEvaluation`` to exit - minimize early and return that point to Xopt. - 4. Xopt evaluates the point externally and appends it to ``self.data``. - 5. The next ``step`` repeats the process with the larger cache. - - Performance implications - ------------------------ - - ``_build_cache`` is O(N) in number of past evaluations and is executed every - ``step``. This overhead is typically small when objective evaluations are - expensive, but can dominate for very cheap objectives. - - ``minimize`` is restarted from ``x0`` each ``step`` and progresses by replaying - cached points. This is robust and method-agnostic, but adds repeated optimizer - bookkeeping work compared to a persistent in-memory scipy run. - - Point keys are rounded (12 decimals) before cache lookup to avoid fragile - floating-point equality checks. This improves replay stability across methods - that revisit numerically-close points. + 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 replaying previously-evaluated points from ``self.data``: + + 1. Build a cache from existing Xopt observations. + 2. Run ``scipy.optimize.minimize`` with an objective function that first checks + the cache. + 3. If scipy asks for a point not in cache, raise ``_RequestEvaluation`` to exit + minimize early and return that point to Xopt. + 4. Xopt evaluates the point externally and appends it to ``self.data``. + 5. The next ``step`` repeats the process with the larger cache. + + Performance implications + ------------------------ + - ``_build_cache`` is O(N) in number of past evaluations and is executed every + ``step``. This overhead is typically small when objective evaluations are + expensive, but can dominate for very cheap objectives. + - ``minimize`` is restarted from ``x0`` each ``step`` and progresses by replaying + cached points. This is robust and method-agnostic, but adds repeated optimizer + bookkeeping work compared to a persistent in-memory scipy run. + - Point keys are rounded (12 decimals) before cache lookup to avoid fragile + floating-point equality checks. This improves replay stability across methods + that revisit numerically-close points. """ diff --git a/xopt/tests/generators/sequential/test_scipy.py b/xopt/tests/generators/sequential/test_scipy.py index 04e534dbf..2e2d617e9 100644 --- a/xopt/tests/generators/sequential/test_scipy.py +++ b/xopt/tests/generators/sequential/test_scipy.py @@ -12,6 +12,44 @@ def sphere(input_dict): 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: From 38a180ab77839bb4bf94f07ca65ff0a613e4755f Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 27 May 2026 14:34:16 -0700 Subject: [PATCH 06/27] linting --- docs/examples/sequential/scipy.ipynb | 35 ++++++---------------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/docs/examples/sequential/scipy.ipynb b/docs/examples/sequential/scipy.ipynb index 08c3a6a7e..d494a2ca4 100644 --- a/docs/examples/sequential/scipy.ipynb +++ b/docs/examples/sequential/scipy.ipynb @@ -20,7 +20,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "2517dcff", "metadata": {}, "outputs": [], @@ -43,7 +43,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "7702f19c", "metadata": {}, "outputs": [], @@ -67,7 +67,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "b020f9b7", "metadata": {}, "outputs": [], @@ -98,20 +98,10 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "775e0fe9", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Evaluations: 200\n", - "Best point: {'x0': 0.9999969941651881, 'x1': 0.9999939646341647}\n", - "Best objective: 9.091236787525807e-12\n" - ] - } - ], + "outputs": [], "source": [ "for _ in range(200):\n", " X.step()\n", @@ -134,21 +124,10 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "a5518499", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "ax = X.data[\"y\"].plot(figsize=(7, 4), logy=True)\n", "ax.set_xlabel(\"iteration\")\n", From 5b4d53ab270a1f053d95d9d7095fcc35111b0501 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 27 May 2026 14:47:22 -0700 Subject: [PATCH 07/27] testing updates --- xopt/generators/sequential/scipy.py | 2 + .../tests/generators/sequential/test_scipy.py | 82 ++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/xopt/generators/sequential/scipy.py b/xopt/generators/sequential/scipy.py index 967a4dbec..1935532e4 100644 --- a/xopt/generators/sequential/scipy.py +++ b/xopt/generators/sequential/scipy.py @@ -94,6 +94,8 @@ def x0(self) -> np.ndarray: return self._get_initial_point()[0] def _reset(self): + # Reset replay state so a subsequent run starts from fresh history. + self.data = None self._last_outcome = None def _set_data(self, data: pd.DataFrame): diff --git a/xopt/tests/generators/sequential/test_scipy.py b/xopt/tests/generators/sequential/test_scipy.py index 2e2d617e9..743b35b67 100644 --- a/xopt/tests/generators/sequential/test_scipy.py +++ b/xopt/tests/generators/sequential/test_scipy.py @@ -1,5 +1,6 @@ import json +import pandas as pd import pytest from xopt import Xopt @@ -47,7 +48,9 @@ def test_scipy_generate_single_point(self): 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.random_evaluate( + 1 + ) # generate some data to build an initial point from X_no_init.step() def test_scipy_generate_multiple_points(self): @@ -117,3 +120,80 @@ def test_scipy_generator_direct(self): first = gen.generate(1) assert len(first) == 1 assert set(first[0].keys()) == {"x0", "x1"} + + 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_scipy_sequence_repeats_after_reset(self): + YAML = """ + generator: + name: scipy + method: Powell + initial_point: {x0: 0.75, x1: -0.25} + 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) + gen: ScipyGenerator = X.generator + + def run_sequence(n_steps: int): + seq = [] + for _ in range(n_steps): + candidate = gen.generate(1)[0] + seq.append((candidate["x0"], candidate["x1"])) + + objective = sphere(candidate)["y"] + gen.add_data( + pd.DataFrame( + [ + { + "x0": candidate["x0"], + "x1": candidate["x1"], + "y": objective, + } + ] + ) + ) + return seq + + first_sequence = run_sequence(5) + gen.reset() + second_sequence = run_sequence(5) + + assert second_sequence == pytest.approx(first_sequence) From 42f6071c36759b4e8634f06d8b940775866ac097 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 21:53:20 +0000 Subject: [PATCH 08/27] Fix PydanticUndefinedType not JSON serializable in get_generator_defaults --- xopt/generators/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/xopt/generators/__init__.py b/xopt/generators/__init__.py index 832ed54b4..e8d667c58 100644 --- a/xopt/generators/__init__.py +++ b/xopt/generators/__init__.py @@ -157,6 +157,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 From b5b16860c3dfba0ab40e6915cb3fb27e07f7978e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 22:06:31 +0000 Subject: [PATCH 09/27] Fix lint failures: remove test artifact txt files, add to gitignore, fix notebook ruff format --- .gitignore | 5 +++++ all_sequential_tests.txt | Bin 11436 -> 0 bytes docs/examples/sequential/scipy.ipynb | 2 +- test_results.txt | Bin 2906 -> 0 bytes test_serialization_results.txt | Bin 1224 -> 0 bytes 5 files changed, 6 insertions(+), 1 deletion(-) delete mode 100644 all_sequential_tests.txt delete mode 100644 test_results.txt delete mode 100644 test_serialization_results.txt diff --git a/.gitignore b/.gitignore index 78ebf2da5..4208a811a 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,11 @@ MANIFEST pip-log.txt pip-delete-this-directory.txt +# Test output files +all_sequential_tests.txt +test_results.txt +test_serialization_results.txt + # Unit test / coverage reports htmlcov/ .tox/ diff --git a/all_sequential_tests.txt b/all_sequential_tests.txt deleted file mode 100644 index 6558b2ade80f1a0fdc03d89f6bd938ef04caab85..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11436 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 PQ7_0B+_B|euVR#g3XQc0|tE{R?1 zig>@sGge3Ie79Jc4s?sf_a=Ksx!T%bU)Ks~6=>qx(iZx)C9Kq3Bi1dE+UPd}Uqs$f zS3P}=3TyTr9BT5+`F7PMn)hU=v>PnXIe!Kpa58okT#JVN2#*fhA^fi++6}f9+*`Up zdkvP@rdo1zH;DV-hy!XIVp+hUOJ#XuooeeZ+1SH8C&YfR|DS4hgZ&)lX6Xs`v`5C7 zK2h~IPR9-B#L5%D1Rup-;$>#8q6!8-&WVYw znFWg>F?_35kq+X%rAee%$=YZe`4=%~%cAeAWKZ3j64CP+BX!Ku_sv?z_(|=lA4N>| z!LJ{94L47`(C_d~FCopROGm!o?YDCDNG$6f_qXSQ-*b8T^!iSDn6Woz9|9?$yiQZ|Zvmqx3>9;tf7MDbL=M%#lre JUi!Z Date: Wed, 27 May 2026 22:17:30 +0000 Subject: [PATCH 10/27] Add tests for full ScipyGenerator coverage --- xopt/generators/sequential/scipy.py | 4 +- .../tests/generators/sequential/test_scipy.py | 125 ++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/xopt/generators/sequential/scipy.py b/xopt/generators/sequential/scipy.py index 1935532e4..af57f4060 100644 --- a/xopt/generators/sequential/scipy.py +++ b/xopt/generators/sequential/scipy.py @@ -81,9 +81,7 @@ def validate_method(cls, v: str): @field_validator("initial_point") def validate_initial_point(cls, v: Optional[Dict[str, float]]): - if v is None: - return v - if len(v) == 0: + if v is not None and len(v) == 0: raise ValueError("initial_point cannot be an empty dictionary") return v diff --git a/xopt/tests/generators/sequential/test_scipy.py b/xopt/tests/generators/sequential/test_scipy.py index 743b35b67..319d168a9 100644 --- a/xopt/tests/generators/sequential/test_scipy.py +++ b/xopt/tests/generators/sequential/test_scipy.py @@ -1,4 +1,5 @@ import json +from unittest.mock import patch import pandas as pd import pytest @@ -6,6 +7,7 @@ from xopt import Xopt from xopt.errors import SeqGeneratorError from xopt.generators.sequential.scipy import ScipyGenerator +from xopt.vocs import VOCS def sphere(input_dict): @@ -197,3 +199,126 @@ def run_sequence(n_steps: int): second_sequence = run_sequence(5) assert second_sequence == pytest.approx(first_sequence) + + 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="method cannot be empty"): + 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_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_runtime_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 + """ + X = Xopt.from_yaml(YAML) + with pytest.raises(RuntimeError, match="scipy method .* is not available"): + X.generator.generate(1) + + 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_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] From 6cf6af0e0087b1233006d96efd143258a139ee62 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 27 May 2026 15:29:05 -0700 Subject: [PATCH 11/27] move / update latin hypercube example --- docs/examples/{scipy => other}/latin_hypercube.ipynb | 2 +- mkdocs.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename docs/examples/{scipy => other}/latin_hypercube.ipynb (98%) diff --git a/docs/examples/scipy/latin_hypercube.ipynb b/docs/examples/other/latin_hypercube.ipynb similarity index 98% rename from docs/examples/scipy/latin_hypercube.ipynb rename to docs/examples/other/latin_hypercube.ipynb index 0f9eb4666..0299bd680 100644 --- a/docs/examples/scipy/latin_hypercube.ipynb +++ b/docs/examples/other/latin_hypercube.ipynb @@ -23,7 +23,7 @@ "source": [ "from copy import deepcopy\n", "from xopt import Xopt, Evaluator\n", - "from xopt.generators.scipy.latin_hypercube import LatinHypercubeGenerator\n", + "from xopt.generators.latin_hypercube import LatinHypercubeGenerator\n", "from xopt.resources.test_functions.tnk import evaluate_TNK, tnk_vocs\n", "import matplotlib.pyplot as plt\n", "import numpy as np" diff --git a/mkdocs.yml b/mkdocs.yml index e3695940b..9f792a31d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,7 +66,7 @@ nav: - 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 From 9eb7a74094649f68be1d8b3d660372a4b346f8eb Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 27 May 2026 15:30:23 -0700 Subject: [PATCH 12/27] linting --- xopt/tests/generators/sequential/test_scipy.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/xopt/tests/generators/sequential/test_scipy.py b/xopt/tests/generators/sequential/test_scipy.py index 319d168a9..6f01128d5 100644 --- a/xopt/tests/generators/sequential/test_scipy.py +++ b/xopt/tests/generators/sequential/test_scipy.py @@ -213,7 +213,9 @@ def test_validate_initial_point_empty_dict(self): variables={"x0": [-5, 5], "x1": [-5, 5]}, objectives={"y": "MINIMIZE"}, ) - with pytest.raises(ValueError, match="initial_point cannot be an empty dictionary"): + with pytest.raises( + ValueError, match="initial_point cannot be an empty dictionary" + ): ScipyGenerator(vocs=vocs, initial_point={}) def test_add_data_empty_dataframe(self): From 406239d50777d0c8b1b2a9c4eaccfe04c834fde9 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Tue, 2 Jun 2026 10:57:40 -0500 Subject: [PATCH 13/27] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/algorithms.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/algorithms.md b/docs/algorithms.md index 75f4558a5..8d8517a8c 100644 --- a/docs/algorithms.md +++ b/docs/algorithms.md @@ -66,7 +66,7 @@ These generators serve as wrappers for algorithms implemented in scipy. - [`NelderMeadGenerator`](examples/sequential/neldermead.ipynb): implements Nelder-Mead (simplex) optimization. - [`ScipyGenerator`](examples/sequential/scipy.ipynb): generic wrapper around `scipy.optimize.minimize` methods. -- [`LatinHypercubeGenerator`](examples/scipy/latin_hypercube.ipynb): perform latin hypercube sampling of the evaluation function. +- [`LatinHypercubeGenerator`](examples/other/latin_hypercube.ipynb): performs latin hypercube sampling of the evaluation function. RCDS Generators === From cbf70fdd4c78a6ca68ecbfec7da586365902f3c5 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Tue, 2 Jun 2026 10:58:16 -0500 Subject: [PATCH 14/27] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- xopt/generators/sequential/scipy.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/xopt/generators/sequential/scipy.py b/xopt/generators/sequential/scipy.py index af57f4060..7552321de 100644 --- a/xopt/generators/sequential/scipy.py +++ b/xopt/generators/sequential/scipy.py @@ -154,6 +154,10 @@ def objective(x): raise RuntimeError( f"scipy method '{self.method}' is not available in this environment." ) from ex + if "cannot handle bounds" in msg: + raise RuntimeError( + f"scipy method '{self.method}' does not support bounds; choose a bounded method (e.g. 'Powell', 'L-BFGS-B', 'TNC', 'SLSQP', 'trust-constr')." + ) from ex raise # If scipy converges using only cached data, return the latest known point. From 8d82e11da7674249e1c4cbbcd7f3c6e1fbfa1420 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Tue, 2 Jun 2026 10:58:46 -0500 Subject: [PATCH 15/27] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- xopt/generators/sequential/scipy.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/xopt/generators/sequential/scipy.py b/xopt/generators/sequential/scipy.py index 7552321de..d60caf526 100644 --- a/xopt/generators/sequential/scipy.py +++ b/xopt/generators/sequential/scipy.py @@ -92,10 +92,8 @@ def x0(self) -> np.ndarray: return self._get_initial_point()[0] def _reset(self): - # Reset replay state so a subsequent run starts from fresh history. - self.data = None + # Reset internal replay state without discarding collected observations. self._last_outcome = None - def _set_data(self, data: pd.DataFrame): self.data = data if len(data) > 0: From b1f28b20805d480565a5644a8f7ca9e4a74c97ce Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Tue, 2 Jun 2026 16:16:19 -0500 Subject: [PATCH 16/27] Update test_scipy.py --- .../tests/generators/sequential/test_scipy.py | 45 ------------------- 1 file changed, 45 deletions(-) diff --git a/xopt/tests/generators/sequential/test_scipy.py b/xopt/tests/generators/sequential/test_scipy.py index 6f01128d5..d5ab08189 100644 --- a/xopt/tests/generators/sequential/test_scipy.py +++ b/xopt/tests/generators/sequential/test_scipy.py @@ -155,51 +155,6 @@ def test_scipy_reset(self): assert len(candidate) == 1 assert set(candidate[0].keys()) == {"x0", "x1"} - def test_scipy_sequence_repeats_after_reset(self): - YAML = """ - generator: - name: scipy - method: Powell - initial_point: {x0: 0.75, x1: -0.25} - 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) - gen: ScipyGenerator = X.generator - - def run_sequence(n_steps: int): - seq = [] - for _ in range(n_steps): - candidate = gen.generate(1)[0] - seq.append((candidate["x0"], candidate["x1"])) - - objective = sphere(candidate)["y"] - gen.add_data( - pd.DataFrame( - [ - { - "x0": candidate["x0"], - "x1": candidate["x1"], - "y": objective, - } - ] - ) - ) - return seq - - first_sequence = run_sequence(5) - gen.reset() - second_sequence = run_sequence(5) - - assert second_sequence == pytest.approx(first_sequence) - def test_validate_method_rejects_whitespace_string(self): vocs = VOCS( variables={"x0": [-5, 5], "x1": [-5, 5]}, From 4de94b667ca03d6a4851c1d76526eaa4f9ee5535 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 3 Jun 2026 16:20:42 -0500 Subject: [PATCH 17/27] linting --- xopt/generators/sequential/scipy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/xopt/generators/sequential/scipy.py b/xopt/generators/sequential/scipy.py index d60caf526..0a52bc78d 100644 --- a/xopt/generators/sequential/scipy.py +++ b/xopt/generators/sequential/scipy.py @@ -94,6 +94,7 @@ def x0(self) -> np.ndarray: def _reset(self): # Reset internal replay state without discarding collected observations. self._last_outcome = None + def _set_data(self, data: pd.DataFrame): self.data = data if len(data) > 0: From f47a5513406d20aa1aa5e308e5d52c1035526e4f Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 15 Jul 2026 12:07:48 -0500 Subject: [PATCH 18/27] update generators to use threading instead of replay --- xopt/generators/sequential/scipy.py | 257 ++++++++++++++---- .../tests/generators/sequential/test_scipy.py | 109 ++++++++ .../sequential/test_serialization.py | 4 +- 3 files changed, 318 insertions(+), 52 deletions(-) diff --git a/xopt/generators/sequential/scipy.py b/xopt/generators/sequential/scipy.py index 0a52bc78d..2c9163062 100644 --- a/xopt/generators/sequential/scipy.py +++ b/xopt/generators/sequential/scipy.py @@ -1,20 +1,18 @@ +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, field_validator +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 -class _RequestEvaluation(Exception): - """Internal exception used to request a new external function evaluation.""" - - def __init__(self, x: np.ndarray): - self.x = np.array(x, dtype=float) - super().__init__("scipy.optimize.minimize requested a new point evaluation") +class _StopSession(Exception): + """Internal exception used to terminate the persistent minimize session.""" class ScipyGenerator(SequentialGenerator): @@ -25,27 +23,22 @@ class ScipyGenerator(SequentialGenerator): ----------------- 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 replaying previously-evaluated points from ``self.data``: + the two by running one persistent ``minimize`` call in a worker thread: 1. Build a cache from existing Xopt observations. - 2. Run ``scipy.optimize.minimize`` with an objective function that first checks - the cache. - 3. If scipy asks for a point not in cache, raise ``_RequestEvaluation`` to exit - minimize early and return that point to Xopt. - 4. Xopt evaluates the point externally and appends it to ``self.data``. - 5. The next ``step`` repeats the process with the larger cache. + 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 every - ``step``. This overhead is typically small when objective evaluations are - expensive, but can dominate for very cheap objectives. - - ``minimize`` is restarted from ``x0`` each ``step`` and progresses by replaying - cached points. This is robust and method-agnostic, but adds repeated optimizer - bookkeeping work compared to a persistent in-memory scipy run. + - ``_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. This improves replay stability across methods - that revisit numerically-close points. + floating-point equality checks. """ @@ -71,9 +64,30 @@ class ScipyGenerator(SequentialGenerator): # 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 not value: raise ValueError("method cannot be empty") @@ -81,36 +95,92 @@ def validate_method(cls, v: str): @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: return np.array([self.initial_point[k] for k in self.vocs.variable_names]) return self._get_initial_point()[0] def _reset(self): - # Reset internal replay state without discarding collected observations. + """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 {} @@ -120,18 +190,63 @@ def _build_cache(self) -> dict[tuple, float]: return {self._point_key(x): float(y) for x, y in zip(x_data, y_data)} - def _generate(self, first_gen: bool = False) -> Optional[List[Dict[str, float]]]: - cache = self._build_cache() + 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)) - def objective(x): - key = self._point_key(x) - if key in cache: - return cache[key] - # Exit scipy early when a new point is requested so Xopt can evaluate it. - raise _RequestEvaluation(x) - minimize_kwargs = { "method": self.method, "bounds": bounds, @@ -141,25 +256,69 @@ def objective(x): } try: - minimize(objective, self.x0, **minimize_kwargs) - except _RequestEvaluation as req: - inputs = dict(zip(self.vocs.variable_names, req.x.tolist())) - if self.vocs.constants is not None: - inputs.update(self.vocs.constants) - return [inputs] - except ValueError as ex: - msg = str(ex).lower() - if "unknown solver" in msg: - raise RuntimeError( - f"scipy method '{self.method}' is not available in this environment." - ) from ex - if "cannot handle bounds" in msg: - raise RuntimeError( - f"scipy method '{self.method}' does not support bounds; choose a bounded method (e.g. 'Powell', 'L-BFGS-B', 'TNC', 'SLSQP', 'trust-constr')." - ) from ex - raise + 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) diff --git a/xopt/tests/generators/sequential/test_scipy.py b/xopt/tests/generators/sequential/test_scipy.py index d5ab08189..4f6fa06b5 100644 --- a/xopt/tests/generators/sequential/test_scipy.py +++ b/xopt/tests/generators/sequential/test_scipy.py @@ -1,8 +1,10 @@ import json from unittest.mock import patch +import numpy as np import pandas as pd import pytest +from scipy.optimize import minimize from xopt import Xopt from xopt.errors import SeqGeneratorError @@ -14,6 +16,48 @@ def sphere(input_dict): return {"y": float(sum(v**2 for v in input_dict.values()))} +BOUNDED_METHODS = [ + "Nelder-Mead", + "Powell", + "L-BFGS-B", + "TNC", + "SLSQP", + "trust-constr", + "COBYLA", + "COBYQA", +] + + +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 = """ @@ -102,6 +146,45 @@ def test_scipy_generate_and_restart(self): 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: @@ -123,6 +206,32 @@ def test_scipy_generator_direct(self): 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: diff --git a/xopt/tests/generators/sequential/test_serialization.py b/xopt/tests/generators/sequential/test_serialization.py index 0ea972d68..29b0be5a5 100644 --- a/xopt/tests/generators/sequential/test_serialization.py +++ b/xopt/tests/generators/sequential/test_serialization.py @@ -1,5 +1,3 @@ -import pickle - import numpy as np import pytest from xopt.generators.sequential import ( @@ -51,4 +49,4 @@ def test_serialization_and_restart(self, generator): X2.step() # test pickling - pickle.dumps(X2.generator) + X2.generator.model_dump() From bca804493d96525495bd5d44a22c9c71fe6e0b5a Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 15 Jul 2026 12:27:56 -0500 Subject: [PATCH 19/27] move latin hypercube generator back --- xopt/generators/__init__.py | 2 +- xopt/generators/scipy/__init__.py | 0 xopt/generators/{ => scipy}/latin_hypercube.py | 0 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 xopt/generators/scipy/__init__.py rename xopt/generators/{ => scipy}/latin_hypercube.py (100%) diff --git a/xopt/generators/__init__.py b/xopt/generators/__init__.py index cddf25656..c14e62cdf 100644 --- a/xopt/generators/__init__.py +++ b/xopt/generators/__init__.py @@ -59,7 +59,7 @@ def get_generator_dynamic(name: str) -> type[Generator]: ) elif name in all_generator_names["scipy"]: try: - from xopt.generators.latin_hypercube import LatinHypercubeGenerator + from xopt.generators.scipy.latin_hypercube import LatinHypercubeGenerator from xopt.generators.sequential.neldermead import NelderMeadGenerator from xopt.generators.sequential.scipy import ScipyGenerator diff --git a/xopt/generators/scipy/__init__.py b/xopt/generators/scipy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/xopt/generators/latin_hypercube.py b/xopt/generators/scipy/latin_hypercube.py similarity index 100% rename from xopt/generators/latin_hypercube.py rename to xopt/generators/scipy/latin_hypercube.py From c0bbd6ae54994bc688dda7fc9d3a10d7039c8c36 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 15 Jul 2026 12:29:14 -0500 Subject: [PATCH 20/27] fix import --- docs/examples/other/latin_hypercube.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/examples/other/latin_hypercube.ipynb b/docs/examples/other/latin_hypercube.ipynb index 0299bd680..0f9eb4666 100644 --- a/docs/examples/other/latin_hypercube.ipynb +++ b/docs/examples/other/latin_hypercube.ipynb @@ -23,7 +23,7 @@ "source": [ "from copy import deepcopy\n", "from xopt import Xopt, Evaluator\n", - "from xopt.generators.latin_hypercube import LatinHypercubeGenerator\n", + "from xopt.generators.scipy.latin_hypercube import LatinHypercubeGenerator\n", "from xopt.resources.test_functions.tnk import evaluate_TNK, tnk_vocs\n", "import matplotlib.pyplot as plt\n", "import numpy as np" From cd8a54746f4475d5ba3761672223387c6f7302fd Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 15 Jul 2026 12:42:58 -0500 Subject: [PATCH 21/27] update method validation and tests --- xopt/generators/sequential/scipy.py | 17 +++++++++-- .../tests/generators/sequential/test_scipy.py | 30 ++++++++----------- xopt/tests/generators/test_latin_hypercube.py | 2 +- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/xopt/generators/sequential/scipy.py b/xopt/generators/sequential/scipy.py index 2c9163062..e74f76efe 100644 --- a/xopt/generators/sequential/scipy.py +++ b/xopt/generators/sequential/scipy.py @@ -10,6 +10,17 @@ 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.""" @@ -89,8 +100,10 @@ class ScipyGenerator(SequentialGenerator): def validate_method(cls, v: str): """Ensure scipy method names are not empty after whitespace normalization.""" value = v.strip() - if not value: - raise ValueError("method cannot be empty") + 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") diff --git a/xopt/tests/generators/sequential/test_scipy.py b/xopt/tests/generators/sequential/test_scipy.py index 4f6fa06b5..e167636b7 100644 --- a/xopt/tests/generators/sequential/test_scipy.py +++ b/xopt/tests/generators/sequential/test_scipy.py @@ -3,12 +3,13 @@ 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 ScipyGenerator +from xopt.generators.sequential.scipy import ScipyGenerator, BOUNDED_METHODS from xopt.vocs import VOCS @@ -16,18 +17,6 @@ def sphere(input_dict): return {"y": float(sum(v**2 for v in input_dict.values()))} -BOUNDED_METHODS = [ - "Nelder-Mead", - "Powell", - "L-BFGS-B", - "TNC", - "SLSQP", - "trust-constr", - "COBYLA", - "COBYQA", -] - - def _direct_scipy_sequence(vocs: VOCS, method: str, maxiter: int = 30): mins, maxs = np.array(vocs.bounds).T bounds = list(zip(mins, maxs)) @@ -269,7 +258,10 @@ def test_validate_method_rejects_whitespace_string(self): variables={"x0": [-5, 5], "x1": [-5, 5]}, objectives={"y": "MINIMIZE"}, ) - with pytest.raises(ValueError, match="method cannot be empty"): + 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): @@ -292,7 +284,7 @@ def test_add_data_empty_dataframe(self): gen._add_data(pd.DataFrame()) assert gen._last_outcome is None - def test_unknown_solver_raises_runtime_error(self): + def test_unknown_solver_raises_validation_error(self): YAML = """ generator: name: scipy @@ -306,9 +298,11 @@ def test_unknown_solver_raises_runtime_error(self): evaluator: function: xopt.tests.generators.sequential.test_scipy.sphere """ - X = Xopt.from_yaml(YAML) - with pytest.raises(RuntimeError, match="scipy method .* is not available"): - X.generator.generate(1) + 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.""" diff --git a/xopt/tests/generators/test_latin_hypercube.py b/xopt/tests/generators/test_latin_hypercube.py index 0757a2328..787f2f4ba 100644 --- a/xopt/tests/generators/test_latin_hypercube.py +++ b/xopt/tests/generators/test_latin_hypercube.py @@ -7,7 +7,7 @@ from gest_api.vocs import ContinuousVariable from xopt.errors import VOCSError -from xopt.generators.latin_hypercube import LatinHypercubeGenerator +from xopt.generators.scipy.latin_hypercube import LatinHypercubeGenerator from xopt.resources.testing import TEST_VOCS_BASE From a28406e434ae0c7be6c87e378e8e0f6df668224a Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 15 Jul 2026 13:23:56 -0500 Subject: [PATCH 22/27] add tests to maximize coverage --- .../tests/generators/sequential/test_scipy.py | 98 ++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/xopt/tests/generators/sequential/test_scipy.py b/xopt/tests/generators/sequential/test_scipy.py index e167636b7..90779b392 100644 --- a/xopt/tests/generators/sequential/test_scipy.py +++ b/xopt/tests/generators/sequential/test_scipy.py @@ -1,3 +1,4 @@ +import copy import json from unittest.mock import patch @@ -9,7 +10,11 @@ from xopt import Xopt from xopt.errors import SeqGeneratorError -from xopt.generators.sequential.scipy import ScipyGenerator, BOUNDED_METHODS +from xopt.generators.sequential.scipy import ( + BOUNDED_METHODS, + ScipyGenerator, + _StopSession, +) from xopt.vocs import VOCS @@ -274,6 +279,81 @@ def test_validate_initial_point_empty_dict(self): ): 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]}, @@ -362,6 +442,22 @@ def test_convergence_path_with_constants(self): 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 = """ From 54530c9aff6f67b49050bf2b54dbca0cc72ebc53 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 15 Jul 2026 13:47:07 -0500 Subject: [PATCH 23/27] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- xopt/generators/sequential/scipy.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/xopt/generators/sequential/scipy.py b/xopt/generators/sequential/scipy.py index e74f76efe..a6404e6e7 100644 --- a/xopt/generators/sequential/scipy.py +++ b/xopt/generators/sequential/scipy.py @@ -157,7 +157,12 @@ def __setstate__(self, state): 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: - return np.array([self.initial_point[k] for k in self.vocs.variable_names]) + 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): From 1749e4109aaa1543d53811b7d67b7f8b92f67664 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 15 Jul 2026 13:47:24 -0500 Subject: [PATCH 24/27] minor updates --- docs/api/generators/sequential/scipy.md | 53 +++++++++++++++---- docs/examples/sequential/scipy.ipynb | 2 +- .../sequential/test_serialization.py | 3 -- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/docs/api/generators/sequential/scipy.md b/docs/api/generators/sequential/scipy.md index 23625b112..012053724 100644 --- a/docs/api/generators/sequential/scipy.md +++ b/docs/api/generators/sequential/scipy.md @@ -4,20 +4,53 @@ ## 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 replaying known evaluations: +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 `X.data`. -2. `minimize` is called with an objective wrapper that checks the cache first. -3. If scipy asks for an unseen point, the generator raises an internal signal, exits `minimize`, and returns that point to Xopt. -4. Xopt evaluates that point and appends the result. -5. On the next `step`, `minimize` is called again with the larger cache. +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) per `step`, where N is the number of collected evaluations. -- `minimize` restarts each `step`, so there is repeated optimizer bookkeeping overhead. -- For expensive evaluations, this overhead is usually negligible. -- For cheap synthetic test functions, this overhead can be a significant part of runtime. +- 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 diff --git a/docs/examples/sequential/scipy.ipynb b/docs/examples/sequential/scipy.ipynb index 4deec21c0..23a75b94d 100644 --- a/docs/examples/sequential/scipy.ipynb +++ b/docs/examples/sequential/scipy.ipynb @@ -62,7 +62,7 @@ "source": [ "## Configure `ScipyGenerator`\n", "\n", - "Choose a scipy method using `method`. Here we use `Powell`, but methods such as `Nelder-Mead`, `L-BFGS-B`, and others can also be used." + "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." ] }, { diff --git a/xopt/tests/generators/sequential/test_serialization.py b/xopt/tests/generators/sequential/test_serialization.py index 29b0be5a5..cf6f99e62 100644 --- a/xopt/tests/generators/sequential/test_serialization.py +++ b/xopt/tests/generators/sequential/test_serialization.py @@ -47,6 +47,3 @@ def test_serialization_and_restart(self, generator): for i in range(10): X2.step() - - # test pickling - X2.generator.model_dump() From 929dd09c16ef4d005944b2910b2c8750a9fcb59d Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 15 Jul 2026 13:51:01 -0500 Subject: [PATCH 25/27] remove extraneous files from gitignore --- .gitignore | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.gitignore b/.gitignore index 4208a811a..78ebf2da5 100644 --- a/.gitignore +++ b/.gitignore @@ -43,11 +43,6 @@ MANIFEST pip-log.txt pip-delete-this-directory.txt -# Test output files -all_sequential_tests.txt -test_results.txt -test_serialization_results.txt - # Unit test / coverage reports htmlcov/ .tox/ From cf64fa0a044cad7198d890455b2e68a96bf18f23 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 15 Jul 2026 13:53:13 -0500 Subject: [PATCH 26/27] Update scipy.ipynb --- docs/examples/sequential/scipy.ipynb | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/docs/examples/sequential/scipy.ipynb b/docs/examples/sequential/scipy.ipynb index 23a75b94d..8ab1c9982 100644 --- a/docs/examples/sequential/scipy.ipynb +++ b/docs/examples/sequential/scipy.ipynb @@ -135,16 +135,6 @@ "ax.set_title(\"ScipyGenerator optimization progression\")\n", "plt.tight_layout()" ] - }, - { - "cell_type": "markdown", - "id": "f693d7e1", - "metadata": {}, - "source": [ - "## Notes on performance\n", - "\n", - "`ScipyGenerator` bridges scipy's in-process `minimize` API into Xopt's external evaluation loop by replaying cached points each step. This adds overhead that is small for expensive evaluations but can be noticeable for very fast toy objectives." - ] } ], "metadata": { From eeea483ff4644114cf6b58347cc6d987847d4056 Mon Sep 17 00:00:00 2001 From: Ryan Roussel Date: Wed, 15 Jul 2026 13:57:10 -0500 Subject: [PATCH 27/27] linting --- xopt/generators/sequential/scipy.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/xopt/generators/sequential/scipy.py b/xopt/generators/sequential/scipy.py index a6404e6e7..7db08d73c 100644 --- a/xopt/generators/sequential/scipy.py +++ b/xopt/generators/sequential/scipy.py @@ -157,9 +157,13 @@ def __setstate__(self, state): 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] + 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}") + 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 )