diff --git a/README.md b/README.md index 0562174b..a3ffadb3 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ pip install hyperactive ```bash pip install hyperactive[sklearn-integration] # scikit-learn integration pip install hyperactive[sktime-integration] # sktime/skpro integration +pip install hyperactive[lipo-integration] # lipo global optimizer pip install hyperactive[all_extras] # Everything including Optuna ``` @@ -111,7 +112,7 @@ pip install hyperactive[all_extras] # Everything including Optuna Multiple Backends
- GFO algorithms, Optuna samplers, and sklearn search methods through one unified API. + GFO algorithms, Optuna samplers, sklearn search methods, and lipo's parameter-free global optimizer through one unified API. Stable & Tested
@@ -177,13 +178,13 @@ flowchart TB GFO["GFO
21 algorithms"] OPTUNA["Optuna
8 algorithms"] SKL["sklearn
2 algorithms"] - MORE["...
more to come"] + LIPO["LIPO
1 algorithm"] end OPT --> GFO OPT --> OPTUNA OPT --> SKL - OPT --> MORE + OPT --> LIPO end subgraph OUT["Output"] @@ -366,6 +367,34 @@ best_params = optimizer.solve() +
+LIPO Global Optimizer + +```python +import numpy as np +from hyperactive.opt.lipo import LIPOOptimizer + +def objective(params): + x, y = params["x"], params["y"] + return -(x**2 + y**2) + +search_space = { + "x": np.arange(-5, 5, 0.1), + "y": np.arange(-5, 5, 0.1), +} + +optimizer = LIPOOptimizer( + search_space=search_space, + n_iter=100, + experiment=objective, +) +best_params = optimizer.solve() +``` + +
+ + +
Time Series Forecasting with sktime diff --git a/examples/lipo/lipo_examples.py b/examples/lipo/lipo_examples.py new file mode 100644 index 00000000..22ab533a --- /dev/null +++ b/examples/lipo/lipo_examples.py @@ -0,0 +1,22 @@ +"""Example usage of LIPOOptimizer.""" + +import numpy as np + +from hyperactive.opt.lipo import LIPOOptimizer + + +def objective(params): + """Sphere function — maximum at origin.""" + x, y = params["x"], params["y"] + return -(x**2 + y**2) # max at (0, 0) + + +opt = LIPOOptimizer( + search_space={ + "x": np.arange(-5, 5, 0.1), + "y": np.arange(-5, 5, 0.1), + }, + n_iter=100, + experiment=objective, +) +print(opt.solve()) # {'x': ~0.0, 'y': ~0.0} diff --git a/pyproject.toml b/pyproject.toml index 02b7f415..4e9c2558 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,7 @@ all_extras = [ "optuna<5", "cmaes", # Required for CmaEsOptimizer (optuna's CMA-ES sampler) "lightning", + "lipo", "lightgbm", ] diff --git a/src/hyperactive/opt/__init__.py b/src/hyperactive/opt/__init__.py index da303a23..6d8d88a9 100644 --- a/src/hyperactive/opt/__init__.py +++ b/src/hyperactive/opt/__init__.py @@ -28,6 +28,7 @@ StochasticHillClimbing, TreeStructuredParzenEstimators, ) +from .lipo import LIPOOptimizer from .optuna import ( CmaEsOptimizer, GPOptimizer, @@ -42,6 +43,7 @@ __all__ = [ "GridSearchSk", "RandomSearchSk", + "LIPOOptimizer", "HillClimbing", "RepulsingHillClimbing", "StochasticHillClimbing", diff --git a/src/hyperactive/opt/lipo.py b/src/hyperactive/opt/lipo.py new file mode 100644 index 00000000..94fc29fc --- /dev/null +++ b/src/hyperactive/opt/lipo.py @@ -0,0 +1,59 @@ +"""LIPO optimizer integration for Hyperactive.""" + +import numpy as np + + +class LIPOOptimizer: + """Parameter-free global optimizer via the lipo package.""" + + def __init__(self, search_space, n_iter, experiment, maximize=True): + self.search_space = search_space + self.n_iter = n_iter + self.experiment = experiment + self.maximize = maximize + + def _parse_search_space(self): + lower, upper, cats = {}, {}, {} + for key, values in self.search_space.items(): + # Categorical: list of strings + if isinstance(values, list) and isinstance(values[0], str): + cats[key] = values + else: + arr = np.array(values) + lower[key] = float(arr.min()) + upper[key] = float(arr.max()) + # Store grid so we can snap results back later + self._grids = getattr(self, "_grids", {}) + self._grids[key] = arr + return lower, upper, cats + + def _snap_to_grid(self, params): + """Snap lipo's continuous output to nearest valid grid point.""" + snapped = {} + for key, val in params.items(): + if key in getattr(self, "_grids", {}): + grid = self._grids[key] + snapped[key] = grid[np.argmin(np.abs(grid - val))] + else: + snapped[key] = val # categorical, pass through + return snapped + + def solve(self): + """Run optimizer and return best parameters as a dict.""" + from lipo import GlobalOptimizer + + lower, upper, cats = self._parse_search_space() + + def wrapped(**kwargs): + return self.experiment(self._snap_to_grid(kwargs)) + + opt = GlobalOptimizer( + wrapped, + lower_bounds=lower, + upper_bounds=upper, + categories=cats, + maximize=self.maximize, + ) + opt.run(self.n_iter) + + return self._snap_to_grid(opt.optimum[0]) diff --git a/src/hyperactive/tests/test_lipo.py b/src/hyperactive/tests/test_lipo.py new file mode 100644 index 00000000..a3c9f080 --- /dev/null +++ b/src/hyperactive/tests/test_lipo.py @@ -0,0 +1,55 @@ +"""Tests for LIPOOptimizer.""" + +import numpy as np + +from hyperactive.opt.lipo import LIPOOptimizer + + +def sphere(params): + """Sphere function — minimum at origin.""" + return -(params["x"] ** 2 + params["y"] ** 2) + + +def test_lipo_basic(): + """LIPOOptimizer finds near-zero optimum on a continuous grid.""" + opt = LIPOOptimizer( + search_space={ + "x": np.arange(-5, 5, 0.1), + "y": np.arange(-5, 5, 0.1), + }, + n_iter=50, + experiment=sphere, + ) + best = opt.solve() + assert "x" in best and "y" in best + assert abs(best["x"]) < 1.5 # should be near 0 + + +def test_lipo_categorical(): + """LIPOOptimizer selects the best categorical value.""" + + def fn(p): + return 1.0 if p["kernel"] == "rbf" else 0.0 + + opt = LIPOOptimizer( + search_space={"kernel": ["linear", "rbf", "poly"]}, + n_iter=20, + experiment=fn, + ) + best = opt.solve() + assert best["kernel"] == "rbf" + + +def test_lipo_snap_to_grid(): + """_snap_to_grid maps continuous output to the nearest grid point.""" + + def fn(p): + return -abs(p["x"] - 3) + + opt = LIPOOptimizer( + search_space={"x": np.array([1, 2, 3, 4, 5])}, + n_iter=30, + experiment=fn, + ) + best = opt.solve() + assert best["x"] in [1, 2, 3, 4, 5] # must be on the grid