├── tests ├── __init__.py ├── test_warm_start.py ├── test_free_threaded.py ├── test_termination_criterion.py ├── test_compress_symmetric.py ├── test_cmawm.py ├── test_stats.py ├── test_fuzzing.py └── test_boundary.py ├── requirements-bench.txt ├── setup.cfg ├── .gitignore ├── requirements-dev.txt ├── cmaes ├── cma.py ├── __init__.py ├── _stats.py ├── _warm_start.py ├── _xnes.py ├── _sepcma.py ├── _cmawm.py ├── _mapcma.py ├── _dxnesic.py ├── _catcma.py └── _cma.py ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── bug_report.md │ └── question.md └── workflows │ ├── pypi-publish.yml │ ├── examples.yml │ └── tests.yml ├── examples ├── optuna_sampler.py ├── lra_cma.py ├── quadratic_2d_function.py ├── mapcma.py ├── sep_cma.py ├── ellipsoid_function.py ├── cma_with_margin_integer.py ├── ws_cma.py ├── cma_with_margin_binary.py ├── catcma.py ├── ipop_cma.py ├── bipop_cma.py ├── catcma_with_margin.py ├── safecma.py └── cma_sop.py ├── LICENSE ├── fuzzing.py ├── tools ├── optuna_profile.py ├── ws_cmaes_visualizer.py └── cmaes_visualizer.py ├── benchmark ├── problem_himmelblau.py ├── problem_rosenbrock.py ├── problem_sphere.py ├── README.md ├── problem_rastrigin.py ├── problem_six_hump_camel.py ├── runner.sh └── optuna_solver.py └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-bench.txt: -------------------------------------------------------------------------------- 1 | kurobako 2 | cma 3 | optuna 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | E203, 4 | W503 5 | max-line-length = 100 6 | statistics = True 7 | exclude = venv,build,.eggs 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | dist/ 3 | build/ 4 | __pycache__/ 5 | .mypy_cache/ 6 | *.pyc 7 | .eggs/ 8 | *.egg-info/ 9 | .hypothesis 10 | 11 | tmp/ 12 | benchmark/*.json 13 | *.stats 14 | *.sqlite3 15 | 16 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # install_requires 2 | numpy>=1.20.0 3 | 4 | # for Safe CMA-ES 5 | torch 6 | gpytorch 7 | 8 | # visualization 9 | matplotlib 10 | scipy 11 | 12 | # Fuzzing 13 | hypothesis 14 | atheris 15 | 16 | # lint 17 | mypy 18 | flake8 19 | black 20 | -------------------------------------------------------------------------------- /cmaes/cma.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from ._cma import CMA 4 | 5 | __all__ = ["CMA"] 6 | 7 | warnings.warn( 8 | "This module is deprecated. Please import CMA class from the " 9 | "package root (ex: from cmaes import CMA).", 10 | FutureWarning, 11 | ) 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request" 3 | about: Suggest an idea for new features in cmaes. 4 | title: "" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Feature Request 11 | 12 | *Please write your suggestion here.* 13 | 14 | -------------------------------------------------------------------------------- /cmaes/__init__.py: -------------------------------------------------------------------------------- 1 | from ._cma import CMA # NOQA 2 | from ._sepcma import SepCMA # NOQA 3 | from ._warm_start import get_warm_start_mgd # NOQA 4 | from ._cmawm import CMAwM # NOQA 5 | from ._xnes import XNES # NOQA 6 | from ._dxnesic import DXNESIC # NOQA 7 | from ._catcma import CatCMA # NOQA 8 | from ._mapcma import MAPCMA # NOQA 9 | from ._catcmawm import CatCMAwM # NOQA 10 | 11 | __version__ = "0.12.0" 12 | -------------------------------------------------------------------------------- /tests/test_warm_start.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from unittest import TestCase 3 | from cmaes import CMA, get_warm_start_mgd 4 | 5 | 6 | class TestWarmStartCMA(TestCase): 7 | def test_dimension(self): 8 | optimizer = CMA(mean=np.zeros(10), sigma=1.3) 9 | source_solutions = [(optimizer.ask(), 0.0) for _ in range(100)] 10 | ws_mean, ws_sigma, ws_cov = get_warm_start_mgd(source_solutions) 11 | 12 | self.assertEqual(ws_mean.size, 10) 13 | -------------------------------------------------------------------------------- /examples/optuna_sampler.py: -------------------------------------------------------------------------------- 1 | import optuna 2 | 3 | 4 | def objective(trial: optuna.Trial): 5 | x1 = trial.suggest_float("x1", -4, 4) 6 | x2 = trial.suggest_float("x2", -4, 4) 7 | return (x1 - 3) ** 2 + (10 * (x2 + 2)) ** 2 8 | 9 | 10 | def main(): 11 | optuna.logging.set_verbosity(optuna.logging.INFO) 12 | study = optuna.create_study(sampler=optuna.samplers.CmaEsSampler()) 13 | study.optimize(objective, n_trials=250, gc_after_trial=False) 14 | 15 | 16 | if __name__ == "__main__": 17 | main() 18 | -------------------------------------------------------------------------------- /tests/test_free_threaded.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from cmaes import CMA 4 | 5 | 6 | @pytest.mark.freethreaded(threads=10, iterations=200) 7 | def test_simple_optimization(): 8 | optimizer = CMA(mean=np.zeros(2), sigma=1.3) 9 | 10 | def quadratic(x1: float, x2: float) -> float: 11 | return (x1 - 3) ** 2 + (10 * (x2 + 2)) ** 2 12 | 13 | while True: 14 | solutions = [] 15 | for _ in range(optimizer.population_size): 16 | x = optimizer.ask() 17 | value = quadratic(x[0], x[1]) 18 | solutions.append((x, value)) 19 | optimizer.tell(solutions) 20 | 21 | if optimizer.should_stop(): 22 | break 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report" 3 | about: Create a bug report to improve cmaes 4 | title: "" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Bug reports 11 | 12 | *Please file a bug report here.* 13 | 14 | ## Expected Behavior 15 | 16 | *Please describe the behavior you are expecting* 17 | 18 | ## Current Behavior and Steps to Reproduce 19 | 20 | *What is the current behavior? Please provide detailed steps or example for reproducing.* 21 | 22 | ## Context 23 | 24 | Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions. 25 | 26 | * cmaes version or commit revision: 27 | 28 | -------------------------------------------------------------------------------- /examples/lra_cma.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from cmaes import CMA 3 | 4 | 5 | def rastrigin(x): 6 | dim = len(x) 7 | if dim < 2: 8 | raise ValueError("dimension must be greater one") 9 | return 10 * dim + sum(x**2 - 10 * np.cos(2 * np.pi * x)) 10 | 11 | 12 | if __name__ == "__main__": 13 | dim = 40 14 | optimizer = CMA(mean=3 * np.ones(dim), sigma=2.0, seed=10, lr_adapt=True) 15 | 16 | for generation in range(50000): 17 | solutions = [] 18 | for _ in range(optimizer.population_size): 19 | x = optimizer.ask() 20 | value = rastrigin(x) 21 | if generation % 500 == 0: 22 | print(f"#{generation} {value}") 23 | solutions.append((x, value)) 24 | optimizer.tell(solutions) 25 | 26 | if optimizer.should_stop(): 27 | break 28 | -------------------------------------------------------------------------------- /examples/quadratic_2d_function.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from cmaes import CMA 3 | 4 | 5 | def quadratic(x1, x2): 6 | return (x1 - 3) ** 2 + (10 * (x2 + 2)) ** 2 7 | 8 | 9 | def main(): 10 | optimizer = CMA(mean=np.zeros(2), sigma=1.3) 11 | print(" g f(x1,x2) x1 x2 ") 12 | print("=== ========== ====== ======") 13 | 14 | while True: 15 | solutions = [] 16 | for _ in range(optimizer.population_size): 17 | x = optimizer.ask() 18 | value = quadratic(x[0], x[1]) 19 | solutions.append((x, value)) 20 | print( 21 | f"{optimizer.generation:3d} {value:10.5f}" 22 | f" {x[0]:6.2f} {x[1]:6.2f}" 23 | ) 24 | optimizer.tell(solutions) 25 | 26 | if optimizer.should_stop(): 27 | break 28 | 29 | 30 | if __name__ == "__main__": 31 | main() 32 | -------------------------------------------------------------------------------- /examples/mapcma.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from cmaes import MAPCMA 3 | 4 | 5 | def rosenbrock(x): 6 | dim = len(x) 7 | if dim < 2: 8 | raise ValueError("dimension must be greater one") 9 | return sum(100 * (x[:-1] ** 2 - x[1:]) ** 2 + (x[:-1] - 1) ** 2) 10 | 11 | 12 | if __name__ == "__main__": 13 | dim = 20 14 | optimizer = MAPCMA(mean=np.zeros(dim), sigma=0.5, momentum_r=dim) 15 | print(" evals f(x)") 16 | print("====== ==========") 17 | 18 | evals = 0 19 | while True: 20 | solutions = [] 21 | for _ in range(optimizer.population_size): 22 | x = optimizer.ask() 23 | value = rosenbrock(x) 24 | evals += 1 25 | solutions.append((x, value)) 26 | if evals % 1000 == 0: 27 | print(f"{evals:5d} {value:10.5f}") 28 | optimizer.tell(solutions) 29 | 30 | if optimizer.should_stop(): 31 | break 32 | -------------------------------------------------------------------------------- /examples/sep_cma.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from cmaes import SepCMA 3 | 4 | 5 | def ellipsoid(x): 6 | n = len(x) 7 | if len(x) < 2: 8 | raise ValueError("dimension must be greater one") 9 | return sum([(1000 ** (i / (n - 1)) * x[i]) ** 2 for i in range(n)]) 10 | 11 | 12 | def main(): 13 | dim = 40 14 | optimizer = SepCMA(mean=3 * np.ones(dim), sigma=2.0) 15 | print(" evals f(x)") 16 | print("====== ==========") 17 | 18 | evals = 0 19 | while True: 20 | solutions = [] 21 | for _ in range(optimizer.population_size): 22 | x = optimizer.ask() 23 | value = ellipsoid(x) 24 | evals += 1 25 | solutions.append((x, value)) 26 | if evals % 3000 == 0: 27 | print(f"{evals:5d} {value:10.5f}") 28 | optimizer.tell(solutions) 29 | 30 | if optimizer.should_stop(): 31 | break 32 | 33 | 34 | if __name__ == "__main__": 35 | main() 36 | -------------------------------------------------------------------------------- /examples/ellipsoid_function.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from cmaes import CMA 3 | 4 | 5 | def ellipsoid(x): 6 | n = len(x) 7 | if len(x) < 2: 8 | raise ValueError("dimension must be greater one") 9 | return sum([(1000 ** (i / (n - 1)) * x[i]) ** 2 for i in range(n)]) 10 | 11 | 12 | def main(): 13 | dim = 40 14 | optimizer = CMA(mean=3 * np.ones(dim), sigma=2.0) 15 | print(" evals f(x)") 16 | print("====== ==========") 17 | 18 | evals = 0 19 | while True: 20 | solutions = [] 21 | for _ in range(optimizer.population_size): 22 | x = optimizer.ask() 23 | value = ellipsoid(x) 24 | evals += 1 25 | solutions.append((x, value)) 26 | if evals % 3000 == 0: 27 | print(f"{evals:5d} {value:10.5f}") 28 | optimizer.tell(solutions) 29 | 30 | if optimizer.should_stop(): 31 | break 32 | 33 | 34 | if __name__ == "__main__": 35 | main() 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Question" 3 | about: Ask questions about implementations, features, or any other project-related inquiries 4 | title: "[Question] " 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Summary of the Question 11 | 12 | 13 | 14 | ## Detailed Explanation 15 | 16 | 19 | 20 | ## Context and Environment 21 | 22 | 28 | 29 | ## Additional Information 30 | 31 | 34 | -------------------------------------------------------------------------------- /tests/test_termination_criterion.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from unittest import TestCase 4 | from cmaes import CMA 5 | 6 | 7 | class TestTerminationCriterion(TestCase): 8 | def test_stop_if_objective_values_are_not_changed(self): 9 | optimizer = CMA(mean=np.zeros(2), sigma=1.3) 10 | popsize = optimizer.population_size 11 | rng = np.random.RandomState(seed=1) 12 | 13 | for i in range(optimizer._funhist_term + 1): 14 | self.assertFalse(optimizer.should_stop()) 15 | optimizer.tell([(rng.randn(2), 0.01) for _ in range(popsize)]) 16 | 17 | self.assertTrue(optimizer.should_stop()) 18 | 19 | def test_stop_if_detect_divergent_behavior(self): 20 | optimizer = CMA(mean=np.zeros(2), sigma=1e-4) 21 | popsize = optimizer.population_size 22 | nd_rng = np.random.RandomState(1) 23 | 24 | solutions = [(100 * nd_rng.randn(2), 0.01) for _ in range(popsize)] 25 | optimizer.tell(solutions) 26 | self.assertTrue(optimizer.should_stop()) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 CyberAgent, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /fuzzing.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import atheris 3 | import hypothesis.extra.numpy as npst 4 | from hypothesis import given, strategies as st 5 | 6 | from cmaes import CMA 7 | 8 | 9 | @given(data=st.data()) 10 | def test_cma_tell(data): 11 | dim = data.draw(st.integers(min_value=2, max_value=100)) 12 | mean = data.draw(npst.arrays(dtype=float, shape=dim)) 13 | sigma = data.draw(st.floats(min_value=1e-16)) 14 | n_iterations = data.draw(st.integers(min_value=1)) 15 | try: 16 | optimizer = CMA(mean, sigma) 17 | except AssertionError: 18 | return 19 | popsize = optimizer.population_size 20 | for _ in range(n_iterations): 21 | tell_solutions = data.draw( 22 | st.lists( 23 | st.tuples(npst.arrays(dtype=float, shape=dim), st.floats()), 24 | min_size=popsize, 25 | max_size=popsize, 26 | ) 27 | ) 28 | optimizer.ask() 29 | try: 30 | optimizer.tell(tell_solutions) 31 | except AssertionError: 32 | return 33 | optimizer.ask() 34 | 35 | 36 | atheris.Setup(sys.argv, test_cma_tell.hypothesis.fuzz_one_input) 37 | atheris.Fuzz() 38 | -------------------------------------------------------------------------------- /cmaes/_stats.py: -------------------------------------------------------------------------------- 1 | import math 2 | import numpy as np 3 | 4 | 5 | @np.vectorize 6 | def norm_cdf(x: float, loc: float = 0.0, scale: float = 1.0) -> float: 7 | x = (x - loc) / scale 8 | x = x / 2**0.5 9 | z = abs(x) 10 | 11 | if z < 1 / 2**0.5: 12 | y = 0.5 + 0.5 * math.erf(x) 13 | else: 14 | y = 0.5 * math.erfc(z) 15 | if x > 0: 16 | y = 1.0 - y 17 | 18 | return y 19 | 20 | 21 | @np.vectorize 22 | def chi2_ppf(q: float) -> float: 23 | """ 24 | only deal with the special case df=1, loc=0, scale=1 25 | solve chi2.cdf(x; df=1) = erf(sqrt(x/2)) = q with bisection method 26 | """ 27 | if q == 0: 28 | return 0.0 29 | if q == 1: 30 | return math.inf 31 | a, b = 0.0, 100.0 32 | if q < 0.9: 33 | for _ in range(100): 34 | m = (a + b) / 2 35 | if math.erf(math.sqrt(m / 2)) < q: 36 | a = m 37 | else: 38 | b = m 39 | else: 40 | for _ in range(100): 41 | m = (a + b) / 2 42 | if math.erfc(math.sqrt(m / 2)) > 1.0 - q: 43 | a = m 44 | else: 45 | b = m 46 | return m 47 | -------------------------------------------------------------------------------- /tests/test_compress_symmetric.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from unittest import TestCase 3 | from cmaes._cma import _decompress_symmetric, _compress_symmetric 4 | 5 | 6 | class TestCompressSymmetric(TestCase): 7 | def test_compress_symmetric_odd(self): 8 | sym2d = np.array([[1, 2], [2, 3]]) 9 | actual = _compress_symmetric(sym2d) 10 | expected = np.array([1, 2, 3]) 11 | self.assertTrue(np.all(np.equal(actual, expected))) 12 | 13 | def test_compress_symmetric_even(self): 14 | sym2d = np.array([[1, 2, 3], [2, 4, 5], [3, 5, 6]]) 15 | actual = _compress_symmetric(sym2d) 16 | expected = np.array([1, 2, 3, 4, 5, 6]) 17 | self.assertTrue(np.all(np.equal(actual, expected))) 18 | 19 | def test_decompress_symmetric_odd(self): 20 | sym1d = np.array([1, 2, 3]) 21 | actual = _decompress_symmetric(sym1d) 22 | expected = np.array([[1, 2], [2, 3]]) 23 | self.assertTrue(np.all(np.equal(actual, expected))) 24 | 25 | def test_decompress_symmetric_even(self): 26 | sym1d = np.array([1, 2, 3, 4, 5, 6]) 27 | actual = _decompress_symmetric(sym1d) 28 | expected = np.array([[1, 2, 3], [2, 4, 5], [3, 5, 6]]) 29 | self.assertTrue(np.all(np.equal(actual, expected))) 30 | -------------------------------------------------------------------------------- /tools/optuna_profile.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import cProfile 3 | import logging 4 | import pstats 5 | import optuna 6 | 7 | parser = argparse.ArgumentParser() 8 | parser.add_argument("--storage", choices=["memory", "sqlite"], default="memory") 9 | parser.add_argument("--params", type=int, default=100) 10 | parser.add_argument("--trials", type=int, default=1000) 11 | args = parser.parse_args() 12 | 13 | 14 | def objective(trial: optuna.Trial): 15 | val = 0 16 | for i in range(args.params): 17 | xi = trial.suggest_uniform(str(i), -4, 4) 18 | val += (xi - 2) ** 2 19 | return val 20 | 21 | 22 | def main(): 23 | logging.disable(level=logging.INFO) 24 | storage = None 25 | if args.storage == "sqlite": 26 | storage = f"sqlite:///db-{args.trials}-{args.params}.sqlite3" 27 | sampler = optuna.samplers.CmaEsSampler() 28 | study = optuna.create_study(sampler=sampler, storage=storage) 29 | 30 | profiler = cProfile.Profile() 31 | profiler.runcall( 32 | study.optimize, objective, n_trials=args.trials, gc_after_trial=False 33 | ) 34 | profiler.dump_stats("profile.stats") 35 | 36 | stats = pstats.Stats("profile.stats") 37 | stats.sort_stats("time").print_stats(5) 38 | 39 | 40 | if __name__ == "__main__": 41 | main() 42 | -------------------------------------------------------------------------------- /tests/test_cmawm.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import numpy as np 4 | from numpy.testing import assert_almost_equal 5 | from unittest import TestCase 6 | from cmaes import CMA, CMAwM 7 | 8 | 9 | class TestCMAwM(TestCase): 10 | def test_no_discrete_spaces(self): 11 | mean = np.zeros(2) 12 | bounds = np.array([[-10, 10], [-10, 10]]) 13 | steps = np.array([0, 0]) 14 | sigma = 1.3 15 | seed = 1 16 | 17 | cma_optimizer = CMA(mean=mean, sigma=sigma, bounds=bounds, seed=seed) 18 | with warnings.catch_warnings(): 19 | warnings.simplefilter("ignore", category=UserWarning) 20 | cmawm_optimizer = CMAwM( 21 | mean=mean, sigma=sigma, bounds=bounds, steps=steps, seed=seed 22 | ) 23 | 24 | for i in range(100): 25 | solutions = [] 26 | for _ in range(cma_optimizer.population_size): 27 | cma_x = cma_optimizer.ask() 28 | cmawm_x_encoded, cmawm_x_for_tell = cmawm_optimizer.ask() 29 | assert_almost_equal(cma_x, cmawm_x_encoded) 30 | assert_almost_equal(cma_x, cmawm_x_for_tell) 31 | 32 | objective = (cma_x[0] - 3) ** 2 + cma_x[1] ** 2 33 | solutions.append((cma_x, objective)) 34 | cma_optimizer.tell(solutions) 35 | cmawm_optimizer.tell(solutions) 36 | -------------------------------------------------------------------------------- /examples/cma_with_margin_integer.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from cmaes import CMAwM 3 | 4 | 5 | def ellipsoid_int(x, _): 6 | n = len(x) 7 | if len(x) < 2: 8 | raise ValueError("dimension must be greater one") 9 | return sum([(1000 ** (i / (n - 1)) * x[i]) ** 2 for i in range(n)]) 10 | 11 | 12 | def main(): 13 | integer_dim, continuous_dim = 10, 10 14 | dim = integer_dim + continuous_dim 15 | bounds = np.concatenate( 16 | [ 17 | np.tile([-np.inf, np.inf], (continuous_dim, 1)), 18 | np.tile([-10, 11], (integer_dim, 1)), 19 | ] 20 | ) 21 | steps = np.concatenate([np.zeros(continuous_dim), np.ones(integer_dim)]) 22 | optimizer = CMAwM(mean=5 * np.ones(dim), sigma=2.0, bounds=bounds, steps=steps) 23 | print(" evals f(x)") 24 | print("====== ==========") 25 | 26 | evals = 0 27 | while True: 28 | solutions = [] 29 | for _ in range(optimizer.population_size): 30 | x_for_eval, x_for_tell = optimizer.ask() 31 | value = ellipsoid_int(x_for_eval, integer_dim) 32 | evals += 1 33 | solutions.append((x_for_tell, value)) 34 | if evals % 300 == 0: 35 | print(f"{evals:5d} {value:10.5f}") 36 | optimizer.tell(solutions) 37 | 38 | if optimizer.should_stop(): 39 | break 40 | 41 | 42 | if __name__ == "__main__": 43 | main() 44 | -------------------------------------------------------------------------------- /examples/ws_cma.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from cmaes import CMA, get_warm_start_mgd 3 | 4 | 5 | def source_task(x1: float, x2: float) -> float: 6 | b = 0.4 7 | return (x1 - b) ** 2 + (x2 - b) ** 2 8 | 9 | 10 | def target_task(x1: float, x2: float) -> float: 11 | b = 0.6 12 | return (x1 - b) ** 2 + (x2 - b) ** 2 13 | 14 | 15 | def main() -> None: 16 | # Generate solutions from a source task 17 | source_solutions = [] 18 | for _ in range(1000): 19 | x = np.random.random(2) 20 | value = source_task(x[0], x[1]) 21 | source_solutions.append((x, value)) 22 | 23 | # Estimate a promising distribution of the source task 24 | ws_mean, ws_sigma, ws_cov = get_warm_start_mgd( 25 | source_solutions, gamma=0.1, alpha=0.1 26 | ) 27 | optimizer = CMA(mean=ws_mean, sigma=ws_sigma, cov=ws_cov) 28 | 29 | # Run WS-CMA-ES 30 | print(" g f(x1,x2) x1 x2 ") 31 | print("=== ========== ====== ======") 32 | while True: 33 | solutions = [] 34 | for _ in range(optimizer.population_size): 35 | x = optimizer.ask() 36 | value = target_task(x[0], x[1]) 37 | solutions.append((x, value)) 38 | print( 39 | f"{optimizer.generation:3d} {value:10.5f}" 40 | f" {x[0]:6.2f} {x[1]:6.2f}" 41 | ) 42 | optimizer.tell(solutions) 43 | 44 | if optimizer.should_stop(): 45 | break 46 | 47 | 48 | if __name__ == "__main__": 49 | main() 50 | -------------------------------------------------------------------------------- /examples/cma_with_margin_binary.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from cmaes import CMAwM 3 | 4 | 5 | def ellipsoid_onemax(x, n_zdim): 6 | n = len(x) 7 | n_rdim = n - n_zdim 8 | r = 10 9 | if len(x) < 2: 10 | raise ValueError("dimension must be greater one") 11 | ellipsoid = sum([(1000 ** (i / (n_rdim - 1)) * x[i]) ** 2 for i in range(n_rdim)]) 12 | onemax = n_zdim - (0.0 < x[(n - n_zdim) :]).sum() 13 | return ellipsoid + r * onemax 14 | 15 | 16 | def main(): 17 | binary_dim, continuous_dim = 10, 10 18 | dim = binary_dim + continuous_dim 19 | bounds = np.concatenate( 20 | [ 21 | np.tile([-np.inf, np.inf], (continuous_dim, 1)), 22 | np.tile([0, 1], (binary_dim, 1)), 23 | ] 24 | ) 25 | steps = np.concatenate([np.zeros(continuous_dim), np.ones(binary_dim)]) 26 | optimizer = CMAwM(mean=np.zeros(dim), sigma=2.0, bounds=bounds, steps=steps) 27 | print(" evals f(x)") 28 | print("====== ==========") 29 | 30 | evals = 0 31 | while True: 32 | solutions = [] 33 | for _ in range(optimizer.population_size): 34 | x_for_eval, x_for_tell = optimizer.ask() 35 | value = ellipsoid_onemax(x_for_eval, binary_dim) 36 | evals += 1 37 | solutions.append((x_for_tell, value)) 38 | if evals % 300 == 0: 39 | print(f"{evals:5d} {value:10.5f}") 40 | optimizer.tell(solutions) 41 | 42 | if optimizer.should_stop(): 43 | break 44 | 45 | 46 | if __name__ == "__main__": 47 | main() 48 | -------------------------------------------------------------------------------- /benchmark/problem_himmelblau.py: -------------------------------------------------------------------------------- 1 | from kurobako import problem 2 | from kurobako.problem import Problem 3 | 4 | from typing import List 5 | from typing import Optional 6 | 7 | 8 | class HimmelblauEvaluator(problem.Evaluator): 9 | def __init__(self, params: List[Optional[float]]): 10 | self._x1, self._x2 = params 11 | self._current_step = 0 12 | 13 | def evaluate(self, next_step: int) -> List[float]: 14 | self._current_step = 1 15 | value = (self._x1**2 + self._x2 - 11.0) ** 2 + ( 16 | self._x1 + self._x2**2 - 7.0 17 | ) ** 2 18 | return [value] 19 | 20 | def current_step(self) -> int: 21 | return self._current_step 22 | 23 | 24 | class HimmelblauProblem(problem.Problem): 25 | def create_evaluator( 26 | self, params: List[Optional[float]] 27 | ) -> Optional[problem.Evaluator]: 28 | return HimmelblauEvaluator(params) 29 | 30 | 31 | class HimmelblauProblemFactory(problem.ProblemFactory): 32 | def create_problem(self, seed: int) -> Problem: 33 | return HimmelblauProblem() 34 | 35 | def specification(self) -> problem.ProblemSpec: 36 | params = [ 37 | problem.Var("x1", problem.ContinuousRange(-4, 4)), 38 | problem.Var("x2", problem.ContinuousRange(-4, 4)), 39 | ] 40 | return problem.ProblemSpec( 41 | name="Himmelblau Function", 42 | params=params, 43 | values=[problem.Var("Himmelblau")], 44 | ) 45 | 46 | 47 | if __name__ == "__main__": 48 | runner = problem.ProblemRunner(HimmelblauProblemFactory()) 49 | runner.run() 50 | -------------------------------------------------------------------------------- /tests/test_stats.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from unittest import TestCase 4 | from cmaes import _stats 5 | 6 | 7 | # Test Cases in this file is generated by SciPy v1.9.3 8 | class TestNormCDF(TestCase): 9 | def test_standard_normal_distribution(self): 10 | self.assertAlmostEqual(_stats.norm_cdf(-30), 4.906713927147907e-198, places=205) 11 | self.assertAlmostEqual(_stats.norm_cdf(-10), 7.619853024160469e-24, places=30) 12 | self.assertAlmostEqual(_stats.norm_cdf(-1), 0.15865525393145707) 13 | self.assertAlmostEqual(_stats.norm_cdf(0), 0.5) 14 | self.assertAlmostEqual(_stats.norm_cdf(1), 0.8413447460685429) 15 | self.assertAlmostEqual( 16 | _stats.norm_cdf(8), 17 | 0.9999999999999993338661852249060757458209991455078125, 18 | places=30, 19 | ) 20 | self.assertAlmostEqual(_stats.norm_cdf(10), 1.0) 21 | 22 | def test_mu_and_sigma(self): 23 | self.assertAlmostEqual(_stats.norm_cdf(1, loc=2, scale=3), 0.36944134018176367) 24 | 25 | 26 | class TestChi2PPF(TestCase): 27 | def test(self): 28 | self.assertAlmostEqual(_stats.chi2_ppf(0.0), 0.0) 29 | self.assertAlmostEqual( 30 | _stats.chi2_ppf(0.00000001), 1.5707963267948962e-16, places=25 31 | ) 32 | self.assertAlmostEqual(_stats.chi2_ppf(0.5), 0.454936423119572) 33 | self.assertAlmostEqual(_stats.chi2_ppf(0.99999999), 32.84125335146885) 34 | self.assertAlmostEqual( 35 | _stats.chi2_ppf(0.999999999999999777955395074969), 67.39648382445012 36 | ) 37 | self.assertAlmostEqual(_stats.chi2_ppf(1.0), math.inf) 38 | -------------------------------------------------------------------------------- /benchmark/problem_rosenbrock.py: -------------------------------------------------------------------------------- 1 | from kurobako import problem 2 | from kurobako.problem import Problem 3 | 4 | from typing import List 5 | from typing import Optional 6 | 7 | 8 | class RosenbrockEvaluator(problem.Evaluator): 9 | """ 10 | See https://www.sfu.ca/~ssurjano/rosen.html 11 | """ 12 | 13 | def __init__(self, params: List[Optional[float]]): 14 | self._x1, self._x2 = params 15 | self._current_step = 0 16 | 17 | def evaluate(self, next_step: int) -> List[float]: 18 | self._current_step = 1 19 | value = 100 * (self._x2 - self._x1**2) ** 2 + (self._x1 - 1) ** 2 20 | return [value] 21 | 22 | def current_step(self) -> int: 23 | return self._current_step 24 | 25 | 26 | class RosenbrockProblem(problem.Problem): 27 | def create_evaluator( 28 | self, params: List[Optional[float]] 29 | ) -> Optional[problem.Evaluator]: 30 | return RosenbrockEvaluator(params) 31 | 32 | 33 | class RosenbrockProblemFactory(problem.ProblemFactory): 34 | def create_problem(self, seed: int) -> Problem: 35 | return RosenbrockProblem() 36 | 37 | def specification(self) -> problem.ProblemSpec: 38 | params = [ 39 | problem.Var("x1", problem.ContinuousRange(-5, 10)), 40 | problem.Var("x2", problem.ContinuousRange(-5, 10)), 41 | ] 42 | return problem.ProblemSpec( 43 | name="Rosenbrock Function", 44 | params=params, 45 | values=[problem.Var("Rosenbrock")], 46 | ) 47 | 48 | 49 | if __name__ == "__main__": 50 | runner = problem.ProblemRunner(RosenbrockProblemFactory()) 51 | runner.run() 52 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish distributions to TestPyPI and PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - v*.*.* 7 | 8 | jobs: 9 | build-n-publish: 10 | name: Build and publish Python distributions to TestPyPI and PyPI 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: '3.x' 18 | 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip setuptools 22 | pip install --progress-bar off twine wheel build 23 | - name: Build distribution packages 24 | run: python -m build --sdist --wheel 25 | - name: Verify the distributions 26 | run: twine check dist/* 27 | 28 | - uses: actions/upload-artifact@v4 29 | with: 30 | name: distribution 31 | path: dist/ 32 | 33 | - name: Publish distribution to Test PyPI 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | with: 36 | user: __token__ 37 | password: ${{ secrets.TEST_PYPI_PASSWORD }} 38 | repository_url: https://test.pypi.org/legacy/ 39 | 40 | - name: Publish distribution to PyPI 41 | uses: pypa/gh-action-pypi-publish@release/v1 42 | with: 43 | user: __token__ 44 | password: ${{ secrets.PYPI_PASSWORD }} 45 | 46 | - name: Create GitHub release 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | run: | 50 | export TAGNAME=$(jq --raw-output .ref "$GITHUB_EVENT_PATH" | sed -e "s/refs\/tags\///") 51 | gh release create ${TAGNAME} --draft dist/* 52 | 53 | -------------------------------------------------------------------------------- /benchmark/problem_sphere.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import numpy as np 5 | 6 | from kurobako import problem 7 | from kurobako.problem import Problem 8 | 9 | from typing import Optional 10 | 11 | 12 | class SphereEvaluator(problem.Evaluator): 13 | def __init__(self, params: list[Optional[float]]): 14 | self.n = len(params) 15 | self.x = np.array(params, dtype=float) 16 | self._current_step = 0 17 | 18 | def evaluate(self, next_step: int) -> list[float]: 19 | self._current_step = 1 20 | value = np.mean(self.x**2) 21 | return [value] 22 | 23 | def current_step(self) -> int: 24 | return self._current_step 25 | 26 | 27 | class SphereProblem(problem.Problem): 28 | def create_evaluator( 29 | self, params: list[Optional[float]] 30 | ) -> Optional[problem.Evaluator]: 31 | return SphereEvaluator(params) 32 | 33 | 34 | class SphereProblemFactory(problem.ProblemFactory): 35 | def __init__(self, dim): 36 | self.dim = dim 37 | 38 | def create_problem(self, seed: int) -> Problem: 39 | return SphereProblem() 40 | 41 | def specification(self) -> problem.ProblemSpec: 42 | params = [ 43 | problem.Var(f"x{i + 1}", problem.ContinuousRange(-5.12, 5.12)) 44 | for i in range(self.dim) 45 | ] 46 | return problem.ProblemSpec( 47 | name=f"Sphere (dim={self.dim})", 48 | params=params, 49 | values=[problem.Var("Sphere")], 50 | ) 51 | 52 | 53 | if __name__ == "__main__": 54 | dim = int(sys.argv[1]) if len(sys.argv) == 2 else 2 55 | runner = problem.ProblemRunner(SphereProblemFactory(dim)) 56 | runner.run() 57 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "cmaes" 7 | description = "Lightweight Covariance Matrix Adaptation Evolution Strategy (CMA-ES) implementation for Python 3." 8 | readme = "README.md" 9 | authors = [ 10 | { name = "Masashi Shibata", "email" = "m.shibata1020@gmail.com" } 11 | ] 12 | maintainers = [ 13 | { name = "Masahiro Nomura", "email" = "masahironomura5325@gmail.com" }, 14 | { name = "Ryoki Hamano", "email" = "hamano_ryoki_xa@cyberagent.co.jp" } 15 | ] 16 | requires-python = ">=3.8" 17 | license = {file = "LICENSE"} 18 | classifiers = [ 19 | "Development Status :: 4 - Beta", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Programming Language :: Python :: 3 :: Only", 31 | "Intended Audience :: Science/Research", 32 | ] 33 | dependencies = ["numpy"] 34 | dynamic = ["version"] 35 | 36 | [project.optional-dependencies] 37 | cmawm = ["scipy"] 38 | 39 | [project.urls] 40 | "Homepage" = "https://github.com/CyberAgentAILab/cmaes" 41 | 42 | [tool.setuptools.dynamic] 43 | version = {attr = "cmaes.__version__"} 44 | 45 | [tool.setuptools] 46 | packages = ["cmaes"] 47 | include-package-data = false 48 | 49 | [tool.mypy] 50 | ignore_missing_imports = true 51 | disallow_untyped_defs = true 52 | -------------------------------------------------------------------------------- /benchmark/README.md: -------------------------------------------------------------------------------- 1 | # Continuous benchmarking using kurobako and GitHub Actions 2 | 3 | Benchmark scripts are built on [kurobako](https://github.com/sile/kurobako). 4 | See [Introduction to Kurobako: A Benchmark Tool for Hyperparameter Optimization Algorithms](https://medium.com/optuna/kurobako-a2e3f7b760c7) for more details. 5 | 6 | ## How to run benchmark scripts 7 | 8 | GitHub Actions continuously run the benchmark scripts and comment on your pull request. 9 | If you want to run on your local machines, please execute following after installed kurobako. 10 | 11 | ```console 12 | $ ./benchmark/runner.sh -h 13 | runner.sh is an entrypoint to run benchmarkers. 14 | 15 | Usage: 16 | $ runner.sh 17 | 18 | Problem: 19 | rosenbrock : https://www.sfu.ca/~ssurjano/rosen.html 20 | six-hump-camel : https://www.sfu.ca/~ssurjano/camel6.html 21 | himmelblau : https://en.wikipedia.org/wiki/Himmelblau%27s_function 22 | ackley : https://www.sfu.ca/~ssurjano/ackley.html 23 | rastrigin : https://www.sfu.ca/~ssurjano/rastr.html 24 | 25 | Options: 26 | --help, -h print this 27 | 28 | Example: 29 | $ runner.sh rosenbrock ./tmp/kurobako.json 30 | $ cat ./tmp/kurobako.json | kurobako plot curve --errorbar -o ./tmp 31 | 32 | $ ./benchmark/runner.sh rosenbrock ./tmp/kurobako.json 33 | $ cat ./tmp/kurobako.json | kurobako plot curve --errorbar -o ./tmp 34 | ``` 35 | 36 | `kurobako plot curve` requires gnuplot. If you want to run on Docker container, please execute following: 37 | 38 | ``` 39 | $ docker pull sile/kurobako 40 | $ ./benchmark/runner.sh rosenbrock ./tmp/kurobako.json 41 | $ cat ./tmp/kurobako.json | docker run -v $PWD/tmp/images/:/images/ --rm -i sile/kurobako plot curve 42 | ``` 43 | -------------------------------------------------------------------------------- /benchmark/problem_rastrigin.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import numpy as np 3 | 4 | from kurobako import problem 5 | from kurobako.problem import Problem 6 | 7 | from typing import List 8 | from typing import Optional 9 | 10 | 11 | class RastriginEvaluator(problem.Evaluator): 12 | def __init__(self, params: List[Optional[float]]): 13 | self.n = len(params) 14 | self.x = np.array(params, dtype=float) 15 | self._current_step = 0 16 | 17 | def evaluate(self, next_step: int) -> List[float]: 18 | self._current_step = 1 19 | value = 10 * self.n + np.sum(self.x**2 - 10 * np.cos(2 * np.pi * self.x)) 20 | return [value] 21 | 22 | def current_step(self) -> int: 23 | return self._current_step 24 | 25 | 26 | class RastriginProblem(problem.Problem): 27 | def create_evaluator( 28 | self, params: List[Optional[float]] 29 | ) -> Optional[problem.Evaluator]: 30 | return RastriginEvaluator(params) 31 | 32 | 33 | class RastriginProblemFactory(problem.ProblemFactory): 34 | def __init__(self, dim): 35 | self.dim = dim 36 | 37 | def create_problem(self, seed: int) -> Problem: 38 | return RastriginProblem() 39 | 40 | def specification(self) -> problem.ProblemSpec: 41 | params = [ 42 | problem.Var(f"x{i + 1}", problem.ContinuousRange(-5.12, 5.12)) 43 | for i in range(self.dim) 44 | ] 45 | return problem.ProblemSpec( 46 | name=f"Rastrigin (dim={self.dim})", 47 | params=params, 48 | values=[problem.Var("Rastrigin")], 49 | ) 50 | 51 | 52 | if __name__ == "__main__": 53 | dim = int(sys.argv[1]) if len(sys.argv) == 2 else 2 54 | runner = problem.ProblemRunner(RastriginProblemFactory(dim)) 55 | runner.run() 56 | -------------------------------------------------------------------------------- /benchmark/problem_six_hump_camel.py: -------------------------------------------------------------------------------- 1 | from kurobako import problem 2 | from kurobako.problem import Problem 3 | 4 | from typing import List 5 | from typing import Optional 6 | 7 | 8 | class SixHumpCamelEvaluator(problem.Evaluator): 9 | """ 10 | See https://www.sfu.ca/~ssurjano/camel6.html 11 | """ 12 | 13 | def __init__(self, params: List[Optional[float]]): 14 | self._x1, self._x2 = params 15 | self._current_step = 0 16 | 17 | def evaluate(self, next_step: int) -> List[float]: 18 | self._current_step = 1 19 | value = ( 20 | (4 - 2.1 * (self._x1**2) + (self._x1**4) / 3) * (self._x1**2) 21 | + self._x1 * self._x2 22 | + (-4 + 4 * self._x2**2) * (self._x2**2) 23 | ) 24 | return [value] 25 | 26 | def current_step(self) -> int: 27 | return self._current_step 28 | 29 | 30 | class SixHumpCamelProblem(problem.Problem): 31 | def create_evaluator( 32 | self, params: List[Optional[float]] 33 | ) -> Optional[problem.Evaluator]: 34 | return SixHumpCamelEvaluator(params) 35 | 36 | 37 | class SixHumpCamelProblemFactory(problem.ProblemFactory): 38 | def create_problem(self, seed: int) -> Problem: 39 | return SixHumpCamelProblem() 40 | 41 | def specification(self) -> problem.ProblemSpec: 42 | params = [ 43 | problem.Var("x1", problem.ContinuousRange(-5, 10)), 44 | problem.Var("x2", problem.ContinuousRange(-5, 10)), 45 | ] 46 | return problem.ProblemSpec( 47 | name="Six-Hump Camel Function", 48 | params=params, 49 | values=[problem.Var("Six-Hump Camel")], 50 | ) 51 | 52 | 53 | if __name__ == "__main__": 54 | runner = problem.ProblemRunner(SixHumpCamelProblemFactory()) 55 | runner.run() 56 | -------------------------------------------------------------------------------- /examples/catcma.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from cmaes import CatCMA 3 | 4 | 5 | def sphere_com(x, c): 6 | dim_co = len(x) 7 | dim_ca = len(c) 8 | if dim_co < 2: 9 | raise ValueError("dimension must be greater one") 10 | sphere = sum(x * x) 11 | com = dim_ca - sum(c[:, 0]) 12 | return sphere + com 13 | 14 | 15 | def rosenbrock_clo(x, c): 16 | dim_co = len(x) 17 | dim_ca = len(c) 18 | if dim_co < 2: 19 | raise ValueError("dimension must be greater one") 20 | rosenbrock = sum(100 * (x[:-1] ** 2 - x[1:]) ** 2 + (x[:-1] - 1) ** 2) 21 | clo = dim_ca - (c[:, 0].argmin() + c[:, 0].prod() * dim_ca) 22 | return rosenbrock + clo 23 | 24 | 25 | def mc_proximity(x, c, cat_num): 26 | dim_co = len(x) 27 | dim_ca = len(c) 28 | if dim_co < 2: 29 | raise ValueError("dimension must be greater one") 30 | if dim_co != dim_ca: 31 | raise ValueError( 32 | "number of dimensions of continuous and categorical variables " 33 | "must be equal in mc_proximity" 34 | ) 35 | 36 | c_index = np.argmax(c, axis=1) / cat_num 37 | return sum((x - c_index) ** 2) + sum(c_index) 38 | 39 | 40 | if __name__ == "__main__": 41 | cont_dim = 5 42 | cat_dim = 5 43 | cat_num = np.array([3, 4, 5, 5, 5]) 44 | # cat_num = 3 * np.ones(cat_dim, dtype=np.int64) 45 | optimizer = CatCMA(mean=3.0 * np.ones(cont_dim), sigma=1.0, cat_num=cat_num) 46 | 47 | for generation in range(200): 48 | solutions = [] 49 | for _ in range(optimizer.population_size): 50 | x, c = optimizer.ask() 51 | value = mc_proximity(x, c, cat_num) 52 | if generation % 10 == 0: 53 | print(f"#{generation} {value}") 54 | solutions.append(((x, c), value)) 55 | optimizer.tell(solutions) 56 | 57 | if optimizer.should_stop(): 58 | break 59 | -------------------------------------------------------------------------------- /examples/ipop_cma.py: -------------------------------------------------------------------------------- 1 | import math 2 | import numpy as np 3 | from cmaes import CMA 4 | 5 | 6 | def ackley(x1, x2): 7 | return ( 8 | -20 * math.exp(-0.2 * math.sqrt(0.5 * (x1**2 + x2**2))) 9 | - math.exp(0.5 * (math.cos(2 * math.pi * x1) + math.cos(2 * math.pi * x2))) 10 | + math.e 11 | + 20 12 | ) 13 | 14 | 15 | def main(): 16 | seed = 0 17 | rng = np.random.RandomState(1) 18 | 19 | bounds = np.array([[-32.768, 32.768], [-32.768, 32.768]]) 20 | lower_bounds, upper_bounds = bounds[:, 0], bounds[:, 1] 21 | 22 | mean = lower_bounds + (rng.rand(2) * (upper_bounds - lower_bounds)) 23 | sigma = 32.768 * 2 / 5 # 1/5 of the domain width 24 | optimizer = CMA(mean=mean, sigma=sigma, bounds=bounds, seed=0) 25 | 26 | # Multiplier for increasing population size before each restart. 27 | inc_popsize = 2 28 | 29 | print(" g f(x1,x2) x1 x2 ") 30 | print("=== ========== ====== ======") 31 | for generation in range(200): 32 | solutions = [] 33 | for _ in range(optimizer.population_size): 34 | x = optimizer.ask() 35 | value = ackley(x[0], x[1]) 36 | solutions.append((x, value)) 37 | print(f"{generation:3d} {value:10.5f} {x[0]:6.2f} {x[1]:6.2f}") 38 | optimizer.tell(solutions) 39 | 40 | if optimizer.should_stop(): 41 | seed += 1 42 | popsize = optimizer.population_size * inc_popsize 43 | mean = lower_bounds + (rng.rand(2) * (upper_bounds - lower_bounds)) 44 | optimizer = CMA( 45 | mean=mean, 46 | sigma=sigma, 47 | bounds=bounds, 48 | seed=seed, 49 | population_size=popsize, 50 | ) 51 | print("Restart CMA-ES with popsize={}".format(popsize)) 52 | 53 | 54 | if __name__ == "__main__": 55 | main() 56 | -------------------------------------------------------------------------------- /.github/workflows/examples.yml: -------------------------------------------------------------------------------- 1 | name: Run examples 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/examples.yml' 7 | - 'examples/**.py' 8 | - 'cmaes/**.py' 9 | 10 | jobs: 11 | examples: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | architecture: x64 23 | - name: Install dependencies 24 | run: | 25 | pip install -U pip setuptools 26 | pip install --progress-bar off optuna numpy scipy gpytorch torch 27 | pip install --progress-bar off -U . 28 | - run: python examples/quadratic_2d_function.py 29 | - run: python examples/ipop_cma.py 30 | - run: python examples/bipop_cma.py 31 | - run: python examples/ellipsoid_function.py 32 | - run: python examples/optuna_sampler.py 33 | - run: python examples/lra_cma.py 34 | - run: python examples/ws_cma.py 35 | - run: python examples/cma_with_margin_binary.py 36 | - run: python examples/cma_with_margin_integer.py 37 | - run: python examples/safecma.py 38 | - run: python examples/cma_sop.py 39 | examples-cmawm-without-scipy: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - name: Set up Python 44 | uses: actions/setup-python@v5 45 | with: 46 | python-version: '3.x' 47 | architecture: x64 48 | check-latest: true 49 | - name: Install dependencies 50 | run: | 51 | pip install -U pip setuptools 52 | pip install --progress-bar off -U . 53 | - run: python examples/cma_with_margin_binary.py 54 | - run: python examples/cma_with_margin_integer.py 55 | -------------------------------------------------------------------------------- /tests/test_fuzzing.py: -------------------------------------------------------------------------------- 1 | import hypothesis.extra.numpy as npst 2 | import unittest 3 | from hypothesis import given, strategies as st 4 | 5 | from cmaes import CMA, SepCMA 6 | 7 | 8 | class TestFuzzing(unittest.TestCase): 9 | @given( 10 | data=st.data(), 11 | ) 12 | def test_cma_tell(self, data): 13 | dim = data.draw(st.integers(min_value=1, max_value=100)) 14 | mean = data.draw(npst.arrays(dtype=float, shape=dim)) 15 | sigma = data.draw(st.floats(min_value=1e-16)) 16 | n_iterations = data.draw(st.integers(min_value=1)) 17 | try: 18 | optimizer = CMA(mean, sigma) 19 | except AssertionError: 20 | return 21 | popsize = optimizer.population_size 22 | for _ in range(n_iterations): 23 | tell_solutions = data.draw( 24 | st.lists( 25 | st.tuples(npst.arrays(dtype=float, shape=dim), st.floats()), 26 | min_size=popsize, 27 | max_size=popsize, 28 | ) 29 | ) 30 | optimizer.ask() 31 | try: 32 | optimizer.tell(tell_solutions) 33 | except AssertionError: 34 | return 35 | optimizer.ask() 36 | 37 | @given( 38 | data=st.data(), 39 | ) 40 | def test_sepcma_tell(self, data): 41 | dim = data.draw(st.integers(min_value=2, max_value=100)) 42 | mean = data.draw(npst.arrays(dtype=float, shape=dim)) 43 | sigma = data.draw(st.floats(min_value=1e-16)) 44 | n_iterations = data.draw(st.integers(min_value=1)) 45 | try: 46 | optimizer = SepCMA(mean, sigma) 47 | except AssertionError: 48 | return 49 | popsize = optimizer.population_size 50 | for _ in range(n_iterations): 51 | tell_solutions = data.draw( 52 | st.lists( 53 | st.tuples(npst.arrays(dtype=float, shape=dim), st.floats()), 54 | min_size=popsize, 55 | max_size=popsize, 56 | ) 57 | ) 58 | optimizer.ask() 59 | try: 60 | optimizer.tell(tell_solutions) 61 | except AssertionError: 62 | return 63 | optimizer.ask() 64 | -------------------------------------------------------------------------------- /tests/test_boundary.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from unittest import TestCase 3 | from cmaes import CMA, SepCMA 4 | 5 | 6 | CMA_CLASSES = [CMA, SepCMA] 7 | 8 | 9 | class TestCMABoundary(TestCase): 10 | def test_valid_dimension(self): 11 | for CmaClass in CMA_CLASSES: 12 | with self.subTest(f"Class: {CmaClass.__name__}"): 13 | CmaClass( 14 | mean=np.zeros(2), sigma=1.3, bounds=np.array([[-10, 10], [-10, 10]]) 15 | ) 16 | 17 | def test_invalid_dimension(self): 18 | for CmaClass in CMA_CLASSES: 19 | with self.subTest(f"Class: {CmaClass.__name__}"): 20 | with self.assertRaises(AssertionError): 21 | CmaClass(mean=np.zeros(2), sigma=1.3, bounds=np.array([-10, 10])) 22 | 23 | def test_mean_located_out_of_bounds(self): 24 | mean = np.zeros(5) 25 | bounds = np.empty(shape=(5, 2)) 26 | bounds[:, 0], bounds[:, 1] = 1.0, 5.0 27 | for CmaClass in CMA_CLASSES: 28 | with self.subTest(f"Class: {CmaClass.__name__}"): 29 | with self.assertRaises(AssertionError): 30 | CmaClass(mean=mean, sigma=1.3, bounds=bounds) 31 | 32 | def test_set_valid_bounds(self): 33 | for CmaClass in CMA_CLASSES: 34 | with self.subTest(f"Class: {CmaClass.__name__}"): 35 | optimizer = CmaClass(mean=np.zeros(2), sigma=1.3) 36 | optimizer.set_bounds(bounds=np.array([[-10, 10], [-10, 10]])) 37 | 38 | def test_set_invalid_bounds(self): 39 | for CmaClass in CMA_CLASSES: 40 | with self.subTest(f"Class: {CmaClass.__name__}"): 41 | optimizer = CmaClass(mean=np.zeros(2), sigma=1.3) 42 | with self.assertRaises(AssertionError): 43 | optimizer.set_bounds(bounds=np.array([-10, 10])) 44 | 45 | def test_set_bounds_which_does_not_contain_mean(self): 46 | for CmaClass in CMA_CLASSES: 47 | with self.subTest(f"Class: {CmaClass.__name__}"): 48 | optimizer = CmaClass(mean=np.zeros(2), sigma=1.3) 49 | bounds = np.empty(shape=(5, 2)) 50 | bounds[:, 0], bounds[:, 1] = 1.0, 5.0 51 | with self.assertRaises(AssertionError): 52 | optimizer.set_bounds(bounds) 53 | -------------------------------------------------------------------------------- /cmaes/_warm_start.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | import numpy as np 5 | 6 | 7 | def get_warm_start_mgd( 8 | source_solutions: list[tuple[np.ndarray, float]], 9 | gamma: float = 0.1, 10 | alpha: float = 0.1, 11 | ) -> tuple[np.ndarray, float, np.ndarray]: 12 | """Estimates a promising distribution of the source task, then 13 | returns a multivariate gaussian distribution (the mean vector 14 | and the covariance matrix) used for initialization of the CMA-ES. 15 | 16 | Args: 17 | source_solutions: 18 | List of solutions (parameter, value) on a source task. 19 | 20 | gamma: 21 | top-(gamma x 100)% solutions are selected from a set of solutions 22 | on a source task. (default: 0.1). 23 | 24 | alpha: 25 | prior parameter for the initial covariance matrix (default: 0.1). 26 | 27 | Returns: 28 | The tuple of mean vector, sigma, and covariance matrix. 29 | """ 30 | # Paper: https://arxiv.org/abs/2012.06932 31 | assert 0 < gamma <= 1, "gamma should be in (0, 1]" 32 | 33 | if len(source_solutions) == 0: 34 | raise ValueError("solutions should contain one or more items.") 35 | 36 | # Select top-(gamma x 100)% solutions 37 | source_solutions = sorted(source_solutions, key=lambda t: t[1]) 38 | gamma_n = math.floor(len(source_solutions) * gamma) 39 | assert gamma_n >= 1, "One or more solutions must be selected from a source task" 40 | dim = len(source_solutions[0][0]) 41 | top_gamma_solutions = np.empty( 42 | shape=( 43 | gamma_n, 44 | dim, 45 | ), 46 | dtype=float, 47 | ) 48 | for i in range(gamma_n): 49 | top_gamma_solutions[i] = source_solutions[i][0] 50 | 51 | # Estimation of a Promising Distribution of a Source Task. 52 | first_term = alpha**2 * np.eye(dim) 53 | cov_term = np.zeros(shape=(dim, dim), dtype=float) 54 | for i in range(gamma_n): 55 | cov_term += np.dot( 56 | top_gamma_solutions[i, :].reshape(dim, 1), 57 | top_gamma_solutions[i, :].reshape(dim, 1).T, 58 | ) 59 | 60 | second_term = cov_term / gamma_n 61 | mean_term = np.zeros( 62 | shape=( 63 | dim, 64 | 1, 65 | ), 66 | dtype=float, 67 | ) 68 | for i in range(gamma_n): 69 | mean_term += top_gamma_solutions[i, :].reshape(dim, 1) 70 | mean_term /= gamma_n 71 | 72 | third_term = np.dot(mean_term, mean_term.T) 73 | mu = mean_term 74 | mean = mu[:, 0] 75 | Sigma = first_term + second_term - third_term 76 | det_sigma = np.linalg.det(Sigma) 77 | sigma = math.pow(det_sigma, 1.0 / 2.0 / dim) 78 | cov = Sigma / math.pow(det_sigma, 1.0 / dim) 79 | return mean, sigma, cov 80 | -------------------------------------------------------------------------------- /examples/bipop_cma.py: -------------------------------------------------------------------------------- 1 | import math 2 | import numpy as np 3 | from cmaes import CMA 4 | 5 | 6 | def ackley(x1, x2): 7 | return ( 8 | -20 * math.exp(-0.2 * math.sqrt(0.5 * (x1**2 + x2**2))) 9 | - math.exp(0.5 * (math.cos(2 * math.pi * x1) + math.cos(2 * math.pi * x2))) 10 | + math.e 11 | + 20 12 | ) 13 | 14 | 15 | def main(): 16 | seed = 0 17 | rng = np.random.RandomState(0) 18 | 19 | bounds = np.array([[-32.768, 32.768], [-32.768, 32.768]]) 20 | lower_bounds, upper_bounds = bounds[:, 0], bounds[:, 1] 21 | 22 | mean = lower_bounds + (rng.rand(2) * (upper_bounds - lower_bounds)) 23 | sigma0 = 32.768 * 2 / 5 # 1/5 of the domain width 24 | sigma = sigma0 25 | optimizer = CMA(mean=mean, sigma=sigma, bounds=bounds, seed=0) 26 | 27 | n_restarts = 0 # A small restart doesn't count in the n_restarts 28 | small_n_eval, large_n_eval = 0, 0 29 | popsize0 = optimizer.population_size 30 | inc_popsize = 2 31 | 32 | # Initial run is with "normal" population size; it is 33 | # the large population before first doubling, but its 34 | # budget accounting is the same as in case of small 35 | # population. 36 | poptype = "small" 37 | 38 | while n_restarts <= 5: 39 | solutions = [] 40 | for _ in range(optimizer.population_size): 41 | x = optimizer.ask() 42 | value = ackley(x[0], x[1]) 43 | solutions.append((x, value)) 44 | # print("{:10.5f} {:6.2f} {:6.2f}".format(value, x[0], x[1])) 45 | optimizer.tell(solutions) 46 | 47 | if optimizer.should_stop(): 48 | seed += 1 49 | n_eval = optimizer.population_size * optimizer.generation 50 | if poptype == "small": 51 | small_n_eval += n_eval 52 | else: # poptype == "large" 53 | large_n_eval += n_eval 54 | 55 | if small_n_eval < large_n_eval: 56 | poptype = "small" 57 | popsize_multiplier = inc_popsize**n_restarts 58 | popsize = math.floor( 59 | popsize0 * popsize_multiplier ** (rng.uniform() ** 2) 60 | ) 61 | sigma = sigma0 * 10 ** (-2 * rng.uniform()) 62 | else: 63 | poptype = "large" 64 | n_restarts += 1 65 | popsize = popsize0 * (inc_popsize**n_restarts) 66 | sigma = sigma0 67 | mean = lower_bounds + (rng.rand(2) * (upper_bounds - lower_bounds)) 68 | optimizer = CMA( 69 | mean=mean, 70 | sigma=sigma, 71 | bounds=bounds, 72 | seed=seed, 73 | population_size=popsize, 74 | ) 75 | print("Restart CMA-ES with popsize={} ({})".format(popsize, poptype)) 76 | 77 | 78 | if __name__ == "__main__": 79 | main() 80 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests and linters 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/tests.yml' 7 | - 'pyproject.toml' 8 | - '**.py' 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: '3.x' 19 | architecture: x64 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip setuptools 23 | pip install --progress-bar off numpy matplotlib scipy mypy flake8 black torch gpytorch 24 | - run: flake8 . --show-source --statistics 25 | - run: black --check . 26 | - run: mypy cmaes 27 | test: 28 | runs-on: ubuntu-latest 29 | strategy: 30 | matrix: 31 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: Setup Python${{ matrix.python-version }} 35 | uses: actions/setup-python@v5 36 | with: 37 | python-version: ${{ matrix.python-version }} 38 | architecture: x64 39 | - name: Install dependencies 40 | run: | 41 | python -m pip install --upgrade pip setuptools numpy scipy hypothesis pytest torch gpytorch 42 | pip install --progress-bar off . 43 | - run: python -m pytest tests --ignore=tests/test_free_threaded.py 44 | test-free-threaded: 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v4 48 | # TODO: Replace deadsnakes with setup-python when the support for Python 3.13t is added 49 | - name: Setup Python 3.13t 50 | uses: deadsnakes/action@v3.1.0 51 | with: 52 | python-version: "3.13-dev" 53 | nogil: true 54 | - name: Install dependencies 55 | run: | 56 | python -m pip install --upgrade pip setuptools numpy hypothesis pytest pytest-freethreaded 57 | pip install --progress-bar off . 58 | - run: python -m pytest --threads 1 --iterations 1 tests --ignore=tests/test_free_threaded.py 59 | # TODO: Using `unittest` style causes `pytest-freethreaded` to fail with `ConcurrencyError`. 60 | # Rewriting as top-level functions works, 61 | # so a follow-up is needed to refactor from `unittest` to `pytest`. 62 | - run: python -m pytest --threads 1 --iterations 1 --require-gil-disabled tests/test_free_threaded.py 63 | test-numpy2: 64 | runs-on: ubuntu-latest 65 | steps: 66 | - uses: actions/checkout@v4 67 | - name: Setup Python 68 | uses: actions/setup-python@v5 69 | with: 70 | architecture: x64 71 | - name: Install dependencies 72 | run: | 73 | python -m pip install --upgrade pip setuptools scipy hypothesis pytest torch gpytorch 74 | python -m pip install --pre --upgrade numpy 75 | pip install --progress-bar off . 76 | - run: python -m pytest tests --ignore=tests/test_free_threaded.py -------------------------------------------------------------------------------- /examples/catcma_with_margin.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from cmaes import CatCMAwM 3 | 4 | 5 | def SphereIntCOM(x, z, c): 6 | return sum(x * x) + sum(z * z) + len(c) - sum(c[:, 0]) 7 | 8 | 9 | def SphereInt(x, z): 10 | return sum(x * x) + sum(z * z) 11 | 12 | 13 | def SphereCOM(x, c): 14 | return sum(x * x) + len(c) - sum(c[:, 0]) 15 | 16 | 17 | def f_cont_int_cat(): 18 | # [lower_bound, upper_bound] for each continuous variable 19 | X = [[-5, 5], [-5, 5]] 20 | # possible values for each integer variable 21 | Z = [[-1, 0, 1], [-2, -1, 0, 1, 2]] 22 | # number of categories for each categorical variable 23 | C = [3, 3] 24 | 25 | optimizer = CatCMAwM(x_space=X, z_space=Z, c_space=C) 26 | 27 | for generation in range(50): 28 | solutions = [] 29 | for _ in range(optimizer.population_size): 30 | sol = optimizer.ask() 31 | value = SphereIntCOM(sol.x, sol.z, sol.c) 32 | solutions.append((sol, value)) 33 | print(f"#{generation} {sol} evaluation: {value}") 34 | optimizer.tell(solutions) 35 | 36 | 37 | def f_cont_int(): 38 | # [lower_bound, upper_bound] for each continuous variable 39 | X = [[-np.inf, np.inf], [-np.inf, np.inf]] 40 | # possible values for each integer variable 41 | Z = [[-2, -1, 0, 1, 2], [-2, -1, 0, 1, 2]] 42 | 43 | # initial distribution parameters (Optional) 44 | # If you know a promising solution for X and Z, set init_mean to that value. 45 | init_mean = np.ones(len(X) + len(Z)) 46 | init_cov = np.diag(np.ones(len(X) + len(Z))) 47 | init_sigma = 1.0 48 | 49 | optimizer = CatCMAwM( 50 | x_space=X, z_space=Z, mean=init_mean, cov=init_cov, sigma=init_sigma 51 | ) 52 | 53 | for generation in range(50): 54 | solutions = [] 55 | for _ in range(optimizer.population_size): 56 | sol = optimizer.ask() 57 | value = SphereInt(sol.x, sol.z) 58 | solutions.append((sol, value)) 59 | print(f"#{generation} {sol} evaluation: {value}") 60 | optimizer.tell(solutions) 61 | 62 | 63 | def f_cont_cat(): 64 | # [lower_bound, upper_bound] for each continuous variable 65 | X = [[-5, 5], [-5, 5]] 66 | # number of categories for each categorical variable 67 | C = [3, 5] 68 | 69 | # initial distribution parameters (Optional) 70 | init_cat_param = np.array( 71 | [ 72 | [0.5, 0.3, 0.2, 0.0, 0.0], # zero-padded at the end 73 | [0.2, 0.2, 0.2, 0.2, 0.2], # each row must sum to 1 74 | ] 75 | ) 76 | 77 | optimizer = CatCMAwM(x_space=X, c_space=C, cat_param=init_cat_param) 78 | 79 | for generation in range(50): 80 | solutions = [] 81 | for _ in range(optimizer.population_size): 82 | sol = optimizer.ask() 83 | value = SphereCOM(sol.x, sol.c) 84 | solutions.append((sol, value)) 85 | print(f"#{generation} {sol} evaluation: {value}") 86 | optimizer.tell(solutions) 87 | 88 | 89 | if __name__ == "__main__": 90 | f_cont_int_cat() 91 | # f_cont_int() 92 | # f_cont_cat() 93 | -------------------------------------------------------------------------------- /benchmark/runner.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | KUROBAKO=${KUROBAKO:-kurobako} 6 | DIR=$(cd $(dirname $0); pwd) 7 | REPEATS=${REPEATS:-5} 8 | BUDGET=${BUDGET:-300} 9 | SEED=${SEED:-1} 10 | DIM=${DIM:-2} 11 | SURROGATE_ROOT=${SURROGATE_ROOT:-$(dirname $DIR)/tmp/surrogate-models} 12 | WARM_START=${WARM_START:-0} 13 | 14 | usage() { 15 | cat < 20 | 21 | Problem: 22 | rosenbrock : https://www.sfu.ca/~ssurjano/rosen.html 23 | six-hump-camel : https://www.sfu.ca/~ssurjano/camel6.html 24 | himmelblau : https://en.wikipedia.org/wiki/Himmelblau%27s_function 25 | ackley : https://www.sfu.ca/~ssurjano/ackley.html 26 | rastrigin : https://www.sfu.ca/~ssurjano/rastr.html 27 | sphere : https://www.sfu.ca/~ssurjano/spheref.html 28 | toxic-lightgbm : https://github.com/c-bata/benchmark-warm-starting-cmaes 29 | 30 | Options: 31 | --help, -h print this 32 | 33 | Example: 34 | $ $(basename ${0}) rosenbrock ./tmp/kurobako.json 35 | $ cat ./tmp/kurobako.json | kurobako plot curve --errorbar -o ./tmp 36 | EOF 37 | } 38 | 39 | case "$1" in 40 | himmelblau) 41 | PROBLEM=$($KUROBAKO problem command python $DIR/problem_himmelblau.py) 42 | ;; 43 | rosenbrock) 44 | PROBLEM=$($KUROBAKO problem command python $DIR/problem_rosenbrock.py) 45 | ;; 46 | six-hump-camel) 47 | PROBLEM=$($KUROBAKO problem command python $DIR/problem_six_hump_camel.py) 48 | ;; 49 | ackley) 50 | PROBLEM=$($KUROBAKO problem sigopt --dim $DIM ackley) 51 | ;; 52 | rastrigin) 53 | # "kurobako problem sigopt --dim 8 rastrigin" only accepts 8-dim. 54 | PROBLEM=$($KUROBAKO problem command python $DIR/problem_rastrigin.py $DIM) 55 | ;; 56 | sphere) 57 | # "kurobako problem sigopt --dim 8 rastrigin" only accepts 8-dim. 58 | PROBLEM=$($KUROBAKO problem command python $DIR/problem_sphere.py $DIM) 59 | ;; 60 | toxic-lightgbm) 61 | PROBLEM=$($KUROBAKO problem warm-starting \ 62 | $($KUROBAKO problem surrogate $SURROGATE_ROOT/wscmaes-toxic-source/) \ 63 | $($KUROBAKO problem surrogate $SURROGATE_ROOT/wscmaes-toxic-target/)) 64 | ;; 65 | help|--help|-h) 66 | usage 67 | exit 0 68 | ;; 69 | *) 70 | echo "[Error] Invalid problem '${1}'" 71 | usage 72 | exit 1 73 | ;; 74 | esac 75 | 76 | RANDOM_SOLVER=$($KUROBAKO solver random) 77 | CMAES_SOLVER=$($KUROBAKO solver --name 'cmaes' command -- python $DIR/optuna_solver.py cmaes) 78 | SEP_CMAES_SOLVER=$($KUROBAKO solver --name 'sep-cmaes' command -- python $DIR/optuna_solver.py sep-cmaes) 79 | IPOP_CMAES_SOLVER=$($KUROBAKO solver --name 'ipop-cmaes' command -- python $DIR/optuna_solver.py ipop-cmaes) 80 | IPOP_SEP_CMAES_SOLVER=$($KUROBAKO solver --name 'ipop-sep-cmaes' command -- python $DIR/optuna_solver.py ipop-sep-cmaes) 81 | PYCMA_SOLVER=$($KUROBAKO solver --name 'pycma' command -- python $DIR/optuna_solver.py pycma) 82 | WS_CMAES_SOLVER=$($KUROBAKO solver --name 'ws-cmaes' command -- python $DIR/optuna_solver.py ws-cmaes --warm-starting-trials $WARM_START) 83 | 84 | $KUROBAKO studies \ 85 | --solvers $RANDOM_SOLVER $CMAES_SOLVER $PYCMA_SOLVER \ 86 | --problems $PROBLEM \ 87 | --seed $SEED --repeats $REPEATS --budget $BUDGET \ 88 | | $KUROBAKO run --parallelism 6 > $2 89 | 90 | -------------------------------------------------------------------------------- /examples/safecma.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from cmaes.safe_cma import SafeCMA 3 | 4 | 5 | def example1(): 6 | """ 7 | example with a single safety function 8 | """ 9 | 10 | # number of dimensions 11 | dim = 5 12 | 13 | # objective function 14 | def quadratic(x): 15 | coef = 1000 ** (np.arange(dim) / float(dim - 1)) 16 | return np.sum((x * coef) ** 2) 17 | 18 | # safety function 19 | def safe_function(x): 20 | return x[0] 21 | 22 | # safe seeds 23 | safe_seeds_num = 10 24 | safe_seeds = (np.random.rand(safe_seeds_num, dim) * 2 - 1) * 5 25 | safe_seeds[:, 0] = -np.abs(safe_seeds[:, 0]) 26 | 27 | # evaluation of safe seeds (with a single safety function) 28 | seeds_evals = np.array([quadratic(x) for x in safe_seeds]) 29 | seeds_safe_evals = np.stack([[safe_function(x)] for x in safe_seeds]) 30 | safety_threshold = np.array([0]) 31 | 32 | # optimizer (safe CMA-ES) 33 | optimizer = SafeCMA( 34 | sigma=1.0, 35 | safety_threshold=safety_threshold, 36 | safe_seeds=safe_seeds, 37 | seeds_evals=seeds_evals, 38 | seeds_safe_evals=seeds_safe_evals, 39 | ) 40 | 41 | unsafe_eval_counts = 0 42 | best_eval = np.inf 43 | 44 | for generation in range(400): 45 | solutions = [] 46 | for _ in range(optimizer.population_size): 47 | # Ask a parameter 48 | x = optimizer.ask() 49 | value = quadratic(x) 50 | safe_value = np.array([safe_function(x)]) 51 | 52 | # save best eval 53 | best_eval = np.min((best_eval, value)) 54 | unsafe_eval_counts += safe_value > safety_threshold 55 | 56 | solutions.append((x, value, safe_value)) 57 | 58 | # Tell evaluation values. 59 | optimizer.tell(solutions) 60 | 61 | print(f"#{generation} ({best_eval} {unsafe_eval_counts})") 62 | 63 | if optimizer.should_stop(): 64 | break 65 | 66 | 67 | def example2(): 68 | """ 69 | example with multiple safety functions 70 | """ 71 | 72 | # number of dimensions 73 | dim = 5 74 | 75 | # objective function 76 | def quadratic(x): 77 | coef = 1000 ** (np.arange(dim) / float(dim - 1)) 78 | return np.sum((x * coef) ** 2) 79 | 80 | # safety functions 81 | def safe_function1(x): 82 | return x[0] 83 | 84 | def safe_function2(x): 85 | return x[1] 86 | 87 | # safe seeds 88 | safe_seeds_num = 10 89 | safe_seeds = (np.random.rand(safe_seeds_num, dim) * 2 - 1) * 5 90 | safe_seeds[:, 0] = -np.abs(safe_seeds[:, 0]) 91 | safe_seeds[:, 1] = -np.abs(safe_seeds[:, 1]) 92 | 93 | # evaluation of safe seeds (with multiple safety functions) 94 | seeds_evals = np.array([quadratic(x) for x in safe_seeds]) 95 | seeds_safe_evals = np.stack( 96 | [[safe_function1(x), safe_function2(x)] for x in safe_seeds] 97 | ) 98 | safety_threshold = np.array([0, 0]) 99 | 100 | # optimizer (safe CMA-ES) 101 | optimizer = SafeCMA( 102 | sigma=1.0, 103 | safety_threshold=safety_threshold, 104 | safe_seeds=safe_seeds, 105 | seeds_evals=seeds_evals, 106 | seeds_safe_evals=seeds_safe_evals, 107 | ) 108 | 109 | unsafe_eval_counts = 0 110 | best_eval = np.inf 111 | 112 | for generation in range(400): 113 | solutions = [] 114 | for _ in range(optimizer.population_size): 115 | # Ask a parameter 116 | x = optimizer.ask() 117 | value = quadratic(x) 118 | safe_value = np.array([safe_function1(x), safe_function2(x)]) 119 | 120 | # save best eval 121 | best_eval = np.min((best_eval, value)) 122 | unsafe_eval_counts += safe_value > safety_threshold 123 | 124 | solutions.append((x, value, safe_value)) 125 | 126 | # Tell evaluation values. 127 | optimizer.tell(solutions) 128 | 129 | print(f"#{generation} ({best_eval} {unsafe_eval_counts})") 130 | 131 | if optimizer.should_stop(): 132 | break 133 | 134 | 135 | if __name__ == "__main__": 136 | example1() 137 | example2() 138 | -------------------------------------------------------------------------------- /benchmark/optuna_solver.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import optuna 3 | import warnings 4 | 5 | from kurobako import solver 6 | from kurobako.solver.optuna import OptunaSolverFactory 7 | 8 | warnings.filterwarnings( 9 | "ignore", 10 | category=optuna.exceptions.ExperimentalWarning, 11 | module="optuna.samplers._cmaes", 12 | ) 13 | 14 | parser = argparse.ArgumentParser() 15 | parser.add_argument( 16 | "sampler", 17 | choices=["cmaes", "sep-cmaes", "ipop-cmaes", "ipop-sep-cmaes", "pycma", "ws-cmaes"], 18 | ) 19 | parser.add_argument( 20 | "--loglevel", choices=["debug", "info", "warning", "error"], default="warning" 21 | ) 22 | parser.add_argument("--warm-starting-trials", type=int, default=0) 23 | args = parser.parse_args() 24 | 25 | if args.loglevel == "debug": 26 | optuna.logging.set_verbosity(optuna.logging.DEBUG) 27 | elif args.loglevel == "info": 28 | optuna.logging.set_verbosity(optuna.logging.INFO) 29 | elif args.loglevel == "warning": 30 | optuna.logging.set_verbosity(optuna.logging.WARNING) 31 | elif args.loglevel == "error": 32 | optuna.logging.set_verbosity(optuna.logging.ERROR) 33 | 34 | 35 | def create_cmaes_study(seed): 36 | sampler = optuna.samplers.CmaEsSampler(seed=seed, warn_independent_sampling=True) 37 | return optuna.create_study(sampler=sampler, pruner=optuna.pruners.NopPruner()) 38 | 39 | 40 | def create_sep_cmaes_study(seed): 41 | sampler = optuna.samplers.CmaEsSampler( 42 | seed=seed, warn_independent_sampling=True, use_separable_cma=True 43 | ) 44 | return optuna.create_study(sampler=sampler, pruner=optuna.pruners.NopPruner()) 45 | 46 | 47 | def create_ipop_cmaes_study(seed): 48 | sampler = optuna.samplers.CmaEsSampler( 49 | seed=seed, 50 | warn_independent_sampling=True, 51 | restart_strategy="ipop", 52 | inc_popsize=2, 53 | ) 54 | return optuna.create_study(sampler=sampler, pruner=optuna.pruners.NopPruner()) 55 | 56 | 57 | def create_ipop_sep_cmaes_study(seed): 58 | sampler = optuna.samplers.CmaEsSampler( 59 | seed=seed, 60 | warn_independent_sampling=True, 61 | restart_strategy="ipop", 62 | inc_popsize=2, 63 | use_separable_cma=True, 64 | ) 65 | return optuna.create_study(sampler=sampler, pruner=optuna.pruners.NopPruner()) 66 | 67 | 68 | def create_pycma_study(seed): 69 | sampler = optuna.integration.PyCmaSampler( 70 | seed=seed, 71 | warn_independent_sampling=True, 72 | ) 73 | return optuna.create_study(sampler=sampler, pruner=optuna.pruners.NopPruner()) 74 | 75 | 76 | class WarmStartingCmaEsSampler(optuna.samplers.BaseSampler): 77 | def __init__(self, seed, warm_starting_trials: int) -> None: 78 | self._seed = seed 79 | self._warm_starting = True 80 | self._warm_starting_trials = warm_starting_trials 81 | self._sampler = optuna.samplers.RandomSampler(seed=seed) 82 | self._source_trials = [] 83 | 84 | def infer_relative_search_space(self, study, trial): 85 | return self._sampler.infer_relative_search_space(study, trial) 86 | 87 | def sample_relative( 88 | self, 89 | study, 90 | trial, 91 | search_space, 92 | ): 93 | return self._sampler.sample_relative(study, trial, search_space) 94 | 95 | def sample_independent(self, study, trial, param_name, param_distribution): 96 | return self._sampler.sample_independent( 97 | study, trial, param_name, param_distribution 98 | ) 99 | 100 | def after_trial( 101 | self, 102 | study, 103 | trial, 104 | state, 105 | values, 106 | ): 107 | if not self._warm_starting: 108 | return self._sampler.after_trial(study, trial, state, values) 109 | 110 | if len(self._source_trials) < self._warm_starting_trials: 111 | assert state == optuna.trial.TrialState.PRUNED 112 | 113 | self._source_trials.append( 114 | optuna.create_trial( 115 | params=trial.params, 116 | distributions=trial.distributions, 117 | values=values, 118 | ) 119 | ) 120 | if len(self._source_trials) == self._warm_starting_trials: 121 | self._sampler = optuna.samplers.CmaEsSampler( 122 | seed=self._seed + 1, source_trials=self._source_trials or None 123 | ) 124 | self._warm_starting = False 125 | else: 126 | return self._sampler.after_trial(study, trial, state, values) 127 | 128 | 129 | def create_warm_start_study(seed): 130 | sampler = WarmStartingCmaEsSampler(seed, args.warm_starting_trials) 131 | return optuna.create_study(sampler=sampler, pruner=optuna.pruners.NopPruner()) 132 | 133 | 134 | if __name__ == "__main__": 135 | if args.sampler == "cmaes": 136 | factory = OptunaSolverFactory(create_cmaes_study) 137 | elif args.sampler == "sep-cmaes": 138 | factory = OptunaSolverFactory(create_sep_cmaes_study) 139 | elif args.sampler == "ipop-cmaes": 140 | factory = OptunaSolverFactory(create_ipop_cmaes_study) 141 | elif args.sampler == "ipop-sep-cmaes": 142 | factory = OptunaSolverFactory(create_ipop_sep_cmaes_study) 143 | elif args.sampler == "pycma": 144 | factory = OptunaSolverFactory(create_pycma_study) 145 | elif args.sampler == "ws-cmaes": 146 | factory = OptunaSolverFactory( 147 | create_warm_start_study, warm_starting_trials=args.warm_starting_trials 148 | ) 149 | else: 150 | raise ValueError("unsupported sampler") 151 | 152 | runner = solver.SolverRunner(factory) 153 | runner.run() 154 | -------------------------------------------------------------------------------- /examples/cma_sop.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from cmaes.cmasop import CMASoP 3 | 4 | 5 | def example1(): 6 | """ 7 | example with benchmark on sets of points 8 | """ 9 | 10 | # number of total dimensions 11 | dim = 10 12 | 13 | # number of dimensions in each subspace 14 | subspace_dim = 2 15 | 16 | # number of points in each subspace 17 | point_num = 10 18 | 19 | # objective function 20 | def quadratic(x): 21 | coef = 1000 ** (np.arange(dim) / float(dim - 1)) 22 | return np.sum((x * coef) ** 2) 23 | 24 | # sets_of_points (on [-5, 5]) 25 | discrete_subspace_num = dim // subspace_dim 26 | sets_of_points = ( 27 | 2 * np.random.rand(discrete_subspace_num, point_num, subspace_dim) - 1 28 | ) * 5 29 | 30 | # add the optimal solution (for benchmark function) 31 | sets_of_points[:, -1] = np.zeros(subspace_dim) 32 | np.random.shuffle(sets_of_points) 33 | 34 | # optimizer (CMA-ES-SoP) 35 | optimizer = CMASoP( 36 | sets_of_points=sets_of_points, 37 | mean=np.random.rand(dim) * 4 + 1, 38 | sigma=2.0, 39 | ) 40 | 41 | best_eval = np.inf 42 | eval_count = 0 43 | 44 | for generation in range(200): 45 | solutions = [] 46 | for _ in range(optimizer.population_size): 47 | # Ask a parameter 48 | x, enc_x = optimizer.ask() 49 | value = quadratic(enc_x) 50 | 51 | # save best eval 52 | best_eval = np.min((best_eval, value)) 53 | eval_count += 1 54 | 55 | solutions.append((x, value)) 56 | 57 | # Tell evaluation values. 58 | optimizer.tell(solutions) 59 | 60 | print(f"#{generation} ({best_eval} {eval_count})") 61 | 62 | if best_eval < 1e-4 or optimizer.should_stop(): 63 | break 64 | 65 | 66 | def example2(): 67 | """ 68 | example with benchmark on mixed variable (sets of points and continuous variable) 69 | """ 70 | 71 | # number of total dimensions 72 | dim = 10 73 | 74 | # number of dimensions in each subspace 75 | subspace_dim = 2 76 | 77 | # number of points in each subspace 78 | point_num = 10 79 | 80 | # objective function 81 | def quadratic(x): 82 | coef = 1000 ** (np.arange(dim) / float(dim - 1)) 83 | return np.sum((x * coef) ** 2) 84 | 85 | # sets_of_points (on [-5, 5]) 86 | # almost half of the subspaces are continuous spaces 87 | discrete_subspace_num = (dim // 2) // subspace_dim 88 | sets_of_points = ( 89 | 2 * np.random.rand(discrete_subspace_num, point_num, subspace_dim) - 1 90 | ) * 5 91 | 92 | # add the optimal solution (for benchmark function) 93 | sets_of_points[:, -1] = np.zeros(subspace_dim) 94 | np.random.shuffle(sets_of_points) 95 | 96 | # optimizer (CMA-ES-SoP) 97 | optimizer = CMASoP( 98 | sets_of_points=sets_of_points, 99 | mean=np.random.rand(dim) * 4 + 1, 100 | sigma=2.0, 101 | ) 102 | 103 | best_eval = np.inf 104 | eval_count = 0 105 | 106 | for generation in range(200): 107 | solutions = [] 108 | for _ in range(optimizer.population_size): 109 | # Ask a parameter 110 | x, enc_x = optimizer.ask() 111 | value = quadratic(enc_x) 112 | 113 | # save best eval 114 | best_eval = np.min((best_eval, value)) 115 | eval_count += 1 116 | 117 | solutions.append((x, value)) 118 | 119 | # Tell evaluation values. 120 | optimizer.tell(solutions) 121 | 122 | print(f"#{generation} ({best_eval} {eval_count})") 123 | 124 | if best_eval < 1e-4 or optimizer.should_stop(): 125 | break 126 | 127 | 128 | def example3(): 129 | """ 130 | example with benchmark on mixed variable 131 | (continuous variable and sets of points with different numbers of dimensions and points) 132 | """ 133 | 134 | # numbers of dimensions in each subspace 135 | subspace_dim_list = [2, 3, 5] 136 | cont_dim = 10 137 | 138 | # numbers of points in each subspace 139 | point_num_list = [10, 20, 40] 140 | 141 | # number of total dimensions 142 | dim = int(np.sum(subspace_dim_list) + cont_dim) 143 | 144 | # objective function 145 | def quadratic(x): 146 | coef = 1000 ** (np.arange(dim) / float(dim - 1)) 147 | return np.sum((coef * x) ** 2) 148 | 149 | # sets_of_points (on [-5, 5]) 150 | discrete_subspace_num = len(subspace_dim_list) 151 | sets_of_points = [ 152 | (2 * np.random.rand(point_num_list[i], subspace_dim_list[i]) - 1) * 5 153 | for i in range(discrete_subspace_num) 154 | ] 155 | 156 | # add the optimal solution (for benchmark function) 157 | for i in range(discrete_subspace_num): 158 | sets_of_points[i][-1] = np.zeros(subspace_dim_list[i]) 159 | np.random.shuffle(sets_of_points[i]) 160 | 161 | # optimizer (CMA-ES-SoP) 162 | optimizer = CMASoP( 163 | sets_of_points=sets_of_points, 164 | mean=np.random.rand(dim) * 4 + 1, 165 | sigma=2.0, 166 | ) 167 | 168 | best_eval = np.inf 169 | eval_count = 0 170 | 171 | for generation in range(400): 172 | solutions = [] 173 | for _ in range(optimizer.population_size): 174 | # Ask a parameter 175 | x, enc_x = optimizer.ask() 176 | value = quadratic(enc_x) 177 | 178 | # save best eval 179 | best_eval = np.min((best_eval, value)) 180 | eval_count += 1 181 | 182 | solutions.append((x, value)) 183 | 184 | # Tell evaluation values. 185 | optimizer.tell(solutions) 186 | 187 | print(f"#{generation} ({best_eval} {eval_count})") 188 | 189 | if best_eval < 1e-4 or optimizer.should_stop(): 190 | break 191 | 192 | 193 | if __name__ == "__main__": 194 | example1() 195 | example2() 196 | # example3() 197 | -------------------------------------------------------------------------------- /tools/ws_cmaes_visualizer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Usage: 3 | python3 tools/ws_cmaes_visualizer.py OPTIONS 4 | 5 | Optional arguments: 6 | -h, --help show this help message and exit 7 | --function {quadratic,himmelblau,rosenbrock,six-hump-camel,sphere,rot-ellipsoid} 8 | --seed SEED 9 | --alpha ALPHA 10 | --gamma GAMMA 11 | --frames FRAMES 12 | --interval INTERVAL 13 | --pop-per-frame POP_PER_FRAME 14 | 15 | Example: 16 | python3 ws_cmaes_visualizer.py --function rot-ellipsoid 17 | """ 18 | 19 | import argparse 20 | import math 21 | 22 | import numpy as np 23 | from scipy import stats 24 | 25 | from matplotlib.colors import LinearSegmentedColormap 26 | import matplotlib.pyplot as plt 27 | import matplotlib.animation as animation 28 | from pylab import rcParams 29 | 30 | from cmaes import get_warm_start_mgd 31 | 32 | parser = argparse.ArgumentParser() 33 | parser.add_argument( 34 | "--function", 35 | choices=[ 36 | "quadratic", 37 | "himmelblau", 38 | "rosenbrock", 39 | "six-hump-camel", 40 | "sphere", 41 | "rot-ellipsoid", 42 | ], 43 | ) 44 | parser.add_argument( 45 | "--seed", 46 | type=int, 47 | default=1, 48 | ) 49 | parser.add_argument( 50 | "--alpha", 51 | type=float, 52 | default=0.1, 53 | ) 54 | parser.add_argument( 55 | "--gamma", 56 | type=float, 57 | default=0.1, 58 | ) 59 | parser.add_argument( 60 | "--frames", 61 | type=int, 62 | default=100, 63 | ) 64 | parser.add_argument( 65 | "--interval", 66 | type=int, 67 | default=20, 68 | ) 69 | parser.add_argument( 70 | "--pop-per-frame", 71 | type=int, 72 | default=10, 73 | ) 74 | args = parser.parse_args() 75 | 76 | rcParams["figure.figsize"] = 10, 5 77 | fig, (ax1, ax2) = plt.subplots(1, 2) 78 | 79 | color_dict = { 80 | "red": ((0.0, 0.0, 0.0), (1.0, 1.0, 1.0)), 81 | "green": ((0.0, 0.0, 0.0), (1.0, 1.0, 1.0)), 82 | "blue": ((0.0, 1.0, 1.0), (1.0, 1.0, 1.0)), 83 | "yellow": ((1.0, 1.0, 1.0), (1.0, 1.0, 1.0)), 84 | } 85 | bw = LinearSegmentedColormap("BlueWhile", color_dict) 86 | 87 | 88 | def himmelbleu(x1, x2): 89 | return (x1**2 + x2 - 11.0) ** 2 + (x1 + x2**2 - 7.0) ** 2 90 | 91 | 92 | def himmelbleu_contour(x1, x2): 93 | return np.log(himmelbleu(x1, x2) + 1) 94 | 95 | 96 | def quadratic(x1, x2): 97 | return (x1 - 3) ** 2 + (10 * (x2 + 2)) ** 2 98 | 99 | 100 | def quadratic_contour(x1, x2): 101 | return np.log(quadratic(x1, x2) + 1) 102 | 103 | 104 | def rosenbrock(x1, x2): 105 | return 100 * (x2 - x1**2) ** 2 + (x1 - 1) ** 2 106 | 107 | 108 | def rosenbrock_contour(x1, x2): 109 | return np.log(rosenbrock(x1, x2) + 1) 110 | 111 | 112 | def six_hump_camel(x1, x2): 113 | return ( 114 | (4 - 2.1 * (x1**2) + (x1**4) / 3) * (x1**2) 115 | + x1 * x2 116 | + (-4 + 4 * x2**2) * (x2**2) 117 | ) 118 | 119 | 120 | def six_hump_camel_contour(x1, x2): 121 | return np.log(six_hump_camel(x1, x2) + 1.0316) 122 | 123 | 124 | def sphere(x1, x2): 125 | offset = 0.6 126 | return (x1 - offset) ** 2 + (x2 - offset) ** 2 127 | 128 | 129 | def sphere_contour(x1, x2): 130 | return np.log(sphere(x1, x2) + 1) 131 | 132 | 133 | def ellipsoid(x1, x2): 134 | offset = 0.6 135 | scale = 5**2 136 | return (x1 - offset) ** 2 + scale * (x2 - offset) ** 2 137 | 138 | 139 | def rot_ellipsoid(x1, x2): 140 | rot_x1 = math.sqrt(3.0) / 2.0 * x1 + 1.0 / 2.0 * x2 141 | rot_x2 = 1.0 / 2.0 * x1 + math.sqrt(3.0) / 2.0 * x2 142 | return ellipsoid(rot_x1, rot_x2) 143 | 144 | 145 | def rot_ellipsoid_contour(x1, x2): 146 | return np.log(rot_ellipsoid(x1, x2) + 1) 147 | 148 | 149 | function_name = "" 150 | if args.function == "quadratic": 151 | function_name = "Quadratic function" 152 | objective = quadratic 153 | contour_function = quadratic_contour 154 | global_minimums = [ 155 | (3.0, -2.0), 156 | ] 157 | # input domain 158 | x1_lower_bound, x1_upper_bound = -4, 4 159 | x2_lower_bound, x2_upper_bound = -4, 4 160 | elif args.function == "himmelblau": 161 | function_name = "Himmelblau function" 162 | objective = himmelbleu 163 | contour_function = himmelbleu_contour 164 | global_minimums = [ 165 | (3.0, 2.0), 166 | (-2.805118, 3.131312), 167 | (-3.779310, -3.283186), 168 | (3.584428, -1.848126), 169 | ] 170 | # input domain 171 | x1_lower_bound, x1_upper_bound = -4, 4 172 | x2_lower_bound, x2_upper_bound = -4, 4 173 | elif args.function == "rosenbrock": 174 | # https://www.sfu.ca/~ssurjano/rosen.html 175 | function_name = "Rosenbrock function" 176 | objective = rosenbrock 177 | contour_function = rosenbrock_contour 178 | global_minimums = [ 179 | (1, 1), 180 | ] 181 | # input domain 182 | x1_lower_bound, x1_upper_bound = -5, 10 183 | x2_lower_bound, x2_upper_bound = -5, 10 184 | elif args.function == "six-hump-camel": 185 | # https://www.sfu.ca/~ssurjano/camel6.html 186 | function_name = "Six-hump camel function" 187 | objective = six_hump_camel 188 | contour_function = six_hump_camel_contour 189 | global_minimums = [ 190 | (0.0898, -0.7126), 191 | (-0.0898, 0.7126), 192 | ] 193 | # input domain 194 | x1_lower_bound, x1_upper_bound = -3, 3 195 | x2_lower_bound, x2_upper_bound = -2, 2 196 | elif args.function == "sphere": 197 | function_name = "Sphere function with offset=0.6" 198 | objective = sphere 199 | contour_function = sphere_contour 200 | global_minimums = [ 201 | (0.6, 0.6), 202 | ] 203 | # input domain 204 | x1_lower_bound, x1_upper_bound = 0, 1 205 | x2_lower_bound, x2_upper_bound = 0, 1 206 | elif args.function == "rot-ellipsoid": 207 | function_name = "Rot Ellipsoid function with offset=0.6" 208 | objective = rot_ellipsoid 209 | contour_function = rot_ellipsoid_contour 210 | 211 | global_minimums = [] 212 | # input domain 213 | x1_lower_bound, x1_upper_bound = 0, 1 214 | x2_lower_bound, x2_upper_bound = 0, 1 215 | else: 216 | raise ValueError("invalid function type") 217 | 218 | 219 | seed = args.seed 220 | rng = np.random.RandomState(seed) 221 | solutions = [] 222 | 223 | 224 | def init(): 225 | ax1.set_xlim(x1_lower_bound, x1_upper_bound) 226 | ax1.set_ylim(x2_lower_bound, x2_upper_bound) 227 | ax2.set_xlim(x1_lower_bound, x1_upper_bound) 228 | ax2.set_ylim(x2_lower_bound, x2_upper_bound) 229 | 230 | # Plot 4 local minimum value 231 | for m in global_minimums: 232 | ax1.plot(m[0], m[1], "y*", ms=10) 233 | ax2.plot(m[0], m[1], "y*", ms=10) 234 | 235 | # Plot contour of the function 236 | x1 = np.arange(x1_lower_bound, x1_upper_bound, 0.01) 237 | x2 = np.arange(x2_lower_bound, x2_upper_bound, 0.01) 238 | x1, x2 = np.meshgrid(x1, x2) 239 | 240 | ax1.contour(x1, x2, contour_function(x1, x2), 30, cmap=bw) 241 | 242 | 243 | def update(frame): 244 | for i in range(args.pop_per_frame): 245 | x1 = (x1_upper_bound - x1_lower_bound) * rng.random() + x1_lower_bound 246 | x2 = (x2_upper_bound - x2_lower_bound) * rng.random() + x2_lower_bound 247 | 248 | evaluation = objective(x1, x2) 249 | 250 | # Plot sample points 251 | ax1.plot(x1, x2, "o", c="r", label="2d", alpha=0.5) 252 | 253 | solution = ( 254 | np.array([x1, x2], dtype=float), 255 | evaluation, 256 | ) 257 | solutions.append(solution) 258 | 259 | # Update title 260 | fig.suptitle( 261 | f"WS-CMA-ES {function_name} with alpha={args.alpha} and gamma={args.gamma} (frame={frame})" 262 | ) 263 | 264 | # Plot multivariate gaussian distribution of CMA-ES 265 | x, y = np.mgrid[ 266 | x1_lower_bound:x1_upper_bound:0.01, x2_lower_bound:x2_upper_bound:0.01 267 | ] 268 | 269 | if math.floor(len(solutions) * args.alpha) > 1: 270 | mean, sigma, cov = get_warm_start_mgd( 271 | solutions, alpha=args.alpha, gamma=args.gamma 272 | ) 273 | rv = stats.multivariate_normal(mean, cov) 274 | pos = np.dstack((x, y)) 275 | ax2.contourf(x, y, rv.pdf(pos)) 276 | 277 | if frame % 50 == 0: 278 | print(f"Processing frame {frame}") 279 | 280 | 281 | def main(): 282 | ani = animation.FuncAnimation( 283 | fig, 284 | update, 285 | frames=args.frames, 286 | init_func=init, 287 | blit=False, 288 | interval=args.interval, 289 | ) 290 | ani.save(f"./tmp/{args.function}.mp4") 291 | 292 | 293 | if __name__ == "__main__": 294 | main() 295 | -------------------------------------------------------------------------------- /tools/cmaes_visualizer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Usage: 3 | cmaes_visualizer.py OPTIONS 4 | 5 | Optional arguments: 6 | -h, --help show this help message and exit 7 | --function {quadratic,himmelblau,rosenbrock,six-hump-camel} 8 | --seed SEED 9 | --frames FRAMES 10 | --interval INTERVAL 11 | --pop-per-frame POP_PER_FRAME 12 | --restart-strategy {ipop,bipop} 13 | 14 | Example: 15 | python3 cmaes_visualizer.py --function six-hump-camel --pop-per-frame 2 16 | 17 | python3 tools/cmaes_visualizer.py --function himmelblau \ 18 | --restart-strategy ipop --frames 500 --interval 10 --pop-per-frame 6 19 | """ 20 | 21 | import argparse 22 | import math 23 | 24 | import numpy as np 25 | from scipy import stats 26 | 27 | from matplotlib.colors import LinearSegmentedColormap 28 | import matplotlib.pyplot as plt 29 | import matplotlib.animation as animation 30 | from pylab import rcParams 31 | 32 | from cmaes._cma import CMA 33 | 34 | parser = argparse.ArgumentParser() 35 | parser.add_argument( 36 | "--function", 37 | choices=["quadratic", "himmelblau", "rosenbrock", "six-hump-camel"], 38 | ) 39 | parser.add_argument( 40 | "--seed", 41 | type=int, 42 | default=1, 43 | ) 44 | parser.add_argument( 45 | "--frames", 46 | type=int, 47 | default=100, 48 | ) 49 | parser.add_argument( 50 | "--interval", 51 | type=int, 52 | default=20, 53 | ) 54 | parser.add_argument( 55 | "--pop-per-frame", 56 | type=int, 57 | default=1, 58 | ) 59 | parser.add_argument( 60 | "--restart-strategy", 61 | choices=["ipop", "bipop"], 62 | default="", 63 | ) 64 | args = parser.parse_args() 65 | 66 | rcParams["figure.figsize"] = 10, 5 67 | fig, (ax1, ax2) = plt.subplots(1, 2) 68 | 69 | color_dict = { 70 | "red": ((0.0, 0.0, 0.0), (1.0, 1.0, 1.0)), 71 | "green": ((0.0, 0.0, 0.0), (1.0, 1.0, 1.0)), 72 | "blue": ((0.0, 1.0, 1.0), (1.0, 1.0, 1.0)), 73 | "yellow": ((1.0, 1.0, 1.0), (1.0, 1.0, 1.0)), 74 | } 75 | bw = LinearSegmentedColormap("BlueWhile", color_dict) 76 | 77 | 78 | def himmelblau(x1, x2): 79 | return (x1**2 + x2 - 11.0) ** 2 + (x1 + x2**2 - 7.0) ** 2 80 | 81 | 82 | def himmelblau_contour(x1, x2): 83 | return np.log(himmelblau(x1, x2) + 1) 84 | 85 | 86 | def quadratic(x1, x2): 87 | return (x1 - 3) ** 2 + (10 * (x2 + 2)) ** 2 88 | 89 | 90 | def quadratic_contour(x1, x2): 91 | return np.log(quadratic(x1, x2) + 1) 92 | 93 | 94 | def rosenbrock(x1, x2): 95 | return 100 * (x2 - x1**2) ** 2 + (x1 - 1) ** 2 96 | 97 | 98 | def rosenbrock_contour(x1, x2): 99 | return np.log(rosenbrock(x1, x2) + 1) 100 | 101 | 102 | def six_hump_camel(x1, x2): 103 | return ( 104 | (4 - 2.1 * (x1**2) + (x1**4) / 3) * (x1**2) 105 | + x1 * x2 106 | + (-4 + 4 * x2**2) * (x2**2) 107 | ) 108 | 109 | 110 | def six_hump_camel_contour(x1, x2): 111 | return np.log(six_hump_camel(x1, x2) + 1.0316) 112 | 113 | 114 | function_name = "" 115 | if args.function == "quadratic": 116 | function_name = "Quadratic function" 117 | objective = quadratic 118 | contour_function = quadratic_contour 119 | global_minimums = [ 120 | (3.0, -2.0), 121 | ] 122 | # input domain 123 | x1_lower_bound, x1_upper_bound = -4, 4 124 | x2_lower_bound, x2_upper_bound = -4, 4 125 | elif args.function == "himmelblau": 126 | function_name = "Himmelblau function" 127 | objective = himmelblau 128 | contour_function = himmelblau_contour 129 | global_minimums = [ 130 | (3.0, 2.0), 131 | (-2.805118, 3.131312), 132 | (-3.779310, -3.283186), 133 | (3.584428, -1.848126), 134 | ] 135 | # input domain 136 | x1_lower_bound, x1_upper_bound = -4, 4 137 | x2_lower_bound, x2_upper_bound = -4, 4 138 | elif args.function == "rosenbrock": 139 | # https://www.sfu.ca/~ssurjano/rosen.html 140 | function_name = "Rosenbrock function" 141 | objective = rosenbrock 142 | contour_function = rosenbrock_contour 143 | global_minimums = [ 144 | (1, 1), 145 | ] 146 | # input domain 147 | x1_lower_bound, x1_upper_bound = -5, 10 148 | x2_lower_bound, x2_upper_bound = -5, 10 149 | elif args.function == "six-hump-camel": 150 | # https://www.sfu.ca/~ssurjano/camel6.html 151 | function_name = "Six-hump camel function" 152 | objective = six_hump_camel 153 | contour_function = six_hump_camel_contour 154 | global_minimums = [ 155 | (0.0898, -0.7126), 156 | (-0.0898, 0.7126), 157 | ] 158 | # input domain 159 | x1_lower_bound, x1_upper_bound = -3, 3 160 | x2_lower_bound, x2_upper_bound = -2, 2 161 | else: 162 | raise ValueError("invalid function type") 163 | 164 | 165 | seed = args.seed 166 | bounds = np.array([[x1_lower_bound, x1_upper_bound], [x2_lower_bound, x2_upper_bound]]) 167 | sigma0 = (x1_upper_bound - x2_lower_bound) / 5 168 | optimizer = CMA(mean=np.zeros(2), sigma=sigma0, bounds=bounds, seed=seed) 169 | solutions = [] 170 | trial_number = 0 171 | rng = np.random.RandomState(seed) 172 | 173 | # Variables for IPOP and BIPOP 174 | inc_popsize = 2 175 | n_restarts = 0 # A small restart doesn't count in the n_restarts 176 | small_n_eval, large_n_eval = 0, 0 177 | popsize0 = optimizer.population_size 178 | poptype = "small" 179 | 180 | 181 | def init(): 182 | ax1.set_xlim(x1_lower_bound, x1_upper_bound) 183 | ax1.set_ylim(x2_lower_bound, x2_upper_bound) 184 | ax2.set_xlim(x1_lower_bound, x1_upper_bound) 185 | ax2.set_ylim(x2_lower_bound, x2_upper_bound) 186 | 187 | # Plot 4 local minimum value 188 | for m in global_minimums: 189 | ax1.plot(m[0], m[1], "y*", ms=10) 190 | ax2.plot(m[0], m[1], "y*", ms=10) 191 | 192 | # Plot contour of himmelblau function 193 | x1 = np.arange(x1_lower_bound, x1_upper_bound, 0.01) 194 | x2 = np.arange(x2_lower_bound, x2_upper_bound, 0.01) 195 | x1, x2 = np.meshgrid(x1, x2) 196 | 197 | ax1.contour(x1, x2, contour_function(x1, x2), 30, cmap=bw) 198 | 199 | 200 | def get_next_popsize_sigma(): 201 | global n_restarts, poptype, small_n_eval, large_n_eval 202 | if args.restart_strategy == "ipop": 203 | n_restarts += 1 204 | popsize = optimizer.population_size * inc_popsize 205 | print(f"Restart CMA-ES with popsize={popsize} at trial={trial_number}") 206 | return popsize, sigma0 207 | elif args.restart_strategy == "bipop": 208 | n_eval = optimizer.population_size * optimizer.generation 209 | if poptype == "small": 210 | small_n_eval += n_eval 211 | else: # poptype == "large" 212 | large_n_eval += n_eval 213 | 214 | if small_n_eval < large_n_eval: 215 | poptype = "small" 216 | popsize_multiplier = inc_popsize**n_restarts 217 | popsize = math.floor(popsize0 * popsize_multiplier ** (rng.uniform() ** 2)) 218 | sigma = sigma0 * 10 ** (-2 * rng.uniform()) 219 | else: 220 | poptype = "large" 221 | n_restarts += 1 222 | popsize = popsize0 * (inc_popsize**n_restarts) 223 | sigma = sigma0 224 | print( 225 | f"Restart CMA-ES with popsize={popsize} ({poptype}) at trial={trial_number}" 226 | ) 227 | return popsize, sigma 228 | raise Exception("must not reach here") 229 | 230 | 231 | def update(frame): 232 | global solutions, optimizer, trial_number 233 | if len(solutions) == optimizer.population_size: 234 | optimizer.tell(solutions) 235 | solutions = [] 236 | 237 | if optimizer.should_stop(): 238 | popsize, sigma = get_next_popsize_sigma() 239 | lower_bounds, upper_bounds = bounds[:, 0], bounds[:, 1] 240 | mean = lower_bounds + (rng.rand(2) * (upper_bounds - lower_bounds)) 241 | optimizer = CMA( 242 | mean=mean, 243 | sigma=sigma, 244 | bounds=bounds, 245 | seed=seed, 246 | population_size=popsize, 247 | ) 248 | 249 | n_sample = min(optimizer.population_size - len(solutions), args.pop_per_frame) 250 | for i in range(n_sample): 251 | x = optimizer.ask() 252 | evaluation = objective(x[0], x[1]) 253 | 254 | # Plot sample points 255 | ax1.plot(x[0], x[1], "o", c="r", label="2d", alpha=0.5) 256 | 257 | solution = ( 258 | x, 259 | evaluation, 260 | ) 261 | solutions.append(solution) 262 | trial_number += n_sample 263 | 264 | # Update title 265 | if args.restart_strategy == "ipop": 266 | fig.suptitle( 267 | f"IPOP-CMA-ES {function_name} trial={trial_number} " 268 | f"popsize={optimizer.population_size}" 269 | ) 270 | elif args.restart_strategy == "bipop": 271 | fig.suptitle( 272 | f"BIPOP-CMA-ES {function_name} trial={trial_number} " 273 | f"popsize={optimizer.population_size} ({poptype})" 274 | ) 275 | else: 276 | fig.suptitle(f"CMA-ES {function_name} trial={trial_number}") 277 | 278 | # Plot multivariate gaussian distribution of CMA-ES 279 | x, y = np.mgrid[ 280 | x1_lower_bound:x1_upper_bound:0.01, x2_lower_bound:x2_upper_bound:0.01 281 | ] 282 | rv = stats.multivariate_normal(optimizer._mean, optimizer._C) 283 | pos = np.dstack((x, y)) 284 | ax2.contourf(x, y, rv.pdf(pos)) 285 | 286 | if frame % 50 == 0: 287 | print(f"Processing frame {frame}") 288 | 289 | 290 | def main(): 291 | ani = animation.FuncAnimation( 292 | fig, 293 | update, 294 | frames=args.frames, 295 | init_func=init, 296 | blit=False, 297 | interval=args.interval, 298 | ) 299 | ani.save(f"./tmp/{args.function}.mp4") 300 | 301 | 302 | if __name__ == "__main__": 303 | main() 304 | -------------------------------------------------------------------------------- /cmaes/_xnes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | 5 | import numpy as np 6 | 7 | from typing import cast 8 | from typing import Optional 9 | 10 | 11 | _EPS = 1e-8 12 | _MEAN_MAX = 1e32 13 | _SIGMA_MAX = 1e32 14 | 15 | 16 | class XNES: 17 | """xNES stochastic optimizer class with ask-and-tell interface. 18 | 19 | Example: 20 | 21 | .. code:: 22 | 23 | import numpy as np 24 | from cmaes import XNES 25 | 26 | def quadratic(x1, x2): 27 | return (x1 - 3) ** 2 + (10 * (x2 + 2)) ** 2 28 | 29 | optimizer = XNES(mean=np.zeros(2), sigma=1.3) 30 | 31 | for generation in range(50): 32 | solutions = [] 33 | for _ in range(optimizer.population_size): 34 | # Ask a parameter 35 | x = optimizer.ask() 36 | value = quadratic(x[0], x[1]) 37 | solutions.append((x, value)) 38 | print(f"#{generation} {value} (x1={x[0]}, x2 = {x[1]})") 39 | 40 | # Tell evaluation values. 41 | optimizer.tell(solutions) 42 | 43 | Args: 44 | 45 | mean: 46 | Initial mean vector of multi-variate gaussian distributions. 47 | 48 | sigma: 49 | Initial standard deviation of covariance matrix. 50 | 51 | bounds: 52 | Lower and upper domain boundaries for each parameter (optional). 53 | 54 | n_max_resampling: 55 | A maximum number of resampling parameters (default: 100). 56 | If all sampled parameters are infeasible, the last sampled one 57 | will be clipped with lower and upper bounds. 58 | 59 | seed: 60 | A seed number (optional). 61 | 62 | population_size: 63 | A population size (optional). 64 | 65 | """ 66 | 67 | # Paper: https://dl.acm.org/doi/10.1145/1830483.1830557 68 | 69 | def __init__( 70 | self, 71 | mean: np.ndarray, 72 | sigma: float, 73 | bounds: Optional[np.ndarray] = None, 74 | n_max_resampling: int = 100, 75 | seed: Optional[int] = None, 76 | population_size: Optional[int] = None, 77 | ): 78 | assert sigma > 0, "sigma must be non-zero positive value" 79 | 80 | assert np.all( 81 | np.abs(mean) < _MEAN_MAX 82 | ), f"Abs of all elements of mean vector must be less than {_MEAN_MAX}" 83 | 84 | n_dim = len(mean) 85 | assert n_dim > 1, "The dimension of mean must be larger than 1" 86 | 87 | if population_size is None: 88 | population_size = 4 + math.floor(3 * math.log(n_dim)) 89 | assert population_size > 0, "popsize must be non-zero positive value." 90 | 91 | w_hat = np.log(population_size / 2 + 1) - np.log( 92 | np.arange(1, population_size + 1) 93 | ) 94 | w_hat[np.where(w_hat < 0)] = 0 95 | weights = w_hat / sum(w_hat) - (1.0 / population_size) 96 | 97 | self._n_dim = n_dim 98 | self._popsize = population_size 99 | 100 | # weights 101 | self._weights = weights 102 | 103 | # learning rate 104 | self._eta_mean = 1.0 105 | self._eta_sigma = (3 / 5) * (3 + math.log(n_dim)) / (n_dim * math.sqrt(n_dim)) 106 | self._eta_B = self._eta_sigma 107 | 108 | # distribution parameter 109 | self._mean = mean.copy() 110 | self._sigma = sigma 111 | self._B = np.eye(n_dim) 112 | 113 | # bounds contains low and high of each parameter. 114 | assert bounds is None or _is_valid_bounds(bounds, mean), "invalid bounds" 115 | self._bounds = bounds 116 | self._n_max_resampling = n_max_resampling 117 | 118 | self._g = 0 119 | self._rng = np.random.RandomState(seed) 120 | 121 | # Termination criteria 122 | self._tolx = 1e-12 * sigma 123 | self._tolxup = 1e4 124 | self._tolfun = 1e-12 125 | self._tolconditioncov = 1e14 126 | 127 | self._funhist_term = 10 + math.ceil(30 * n_dim / population_size) 128 | self._funhist_values = np.empty(self._funhist_term * 2) 129 | 130 | @property 131 | def dim(self) -> int: 132 | """A number of dimensions""" 133 | return self._n_dim 134 | 135 | @property 136 | def population_size(self) -> int: 137 | """A population size""" 138 | return self._popsize 139 | 140 | @property 141 | def generation(self) -> int: 142 | """Generation number which is monotonically incremented 143 | when multi-variate gaussian distribution is updated.""" 144 | return self._g 145 | 146 | def reseed_rng(self, seed: int) -> None: 147 | self._rng.seed(seed) 148 | 149 | def set_bounds(self, bounds: Optional[np.ndarray]) -> None: 150 | """Update boundary constraints""" 151 | assert bounds is None or _is_valid_bounds(bounds, self._mean), "invalid bounds" 152 | self._bounds = bounds 153 | 154 | def ask(self) -> np.ndarray: 155 | """Sample a parameter""" 156 | for i in range(self._n_max_resampling): 157 | x = self._sample_solution() 158 | if self._is_feasible(x): 159 | return x 160 | x = self._sample_solution() 161 | x = self._repair_infeasible_params(x) 162 | return x 163 | 164 | def _sample_solution(self) -> np.ndarray: 165 | z = self._rng.randn(self._n_dim) # ~ N(0, I) 166 | x = self._mean + self._sigma * self._B.dot(z) # ~ N(m, σ^2 B B^T) 167 | return x 168 | 169 | def _is_feasible(self, param: np.ndarray) -> bool: 170 | if self._bounds is None: 171 | return True 172 | return cast( 173 | bool, 174 | np.all(param >= self._bounds[:, 0]) and np.all(param <= self._bounds[:, 1]), 175 | ) # Cast bool_ to bool. 176 | 177 | def _repair_infeasible_params(self, param: np.ndarray) -> np.ndarray: 178 | if self._bounds is None: 179 | return param 180 | 181 | # clip with lower and upper bound. 182 | param = np.where(param < self._bounds[:, 0], self._bounds[:, 0], param) 183 | param = np.where(param > self._bounds[:, 1], self._bounds[:, 1], param) 184 | return param 185 | 186 | def tell(self, solutions: list[tuple[np.ndarray, float]]) -> None: 187 | """Tell evaluation values""" 188 | 189 | assert len(solutions) == self._popsize, "Must tell popsize-length solutions." 190 | for s in solutions: 191 | assert np.all( 192 | np.abs(s[0]) < _MEAN_MAX 193 | ), f"Abs of all param values must be less than {_MEAN_MAX} to avoid overflow errors" 194 | 195 | self._g += 1 196 | solutions.sort(key=lambda s: s[1]) 197 | 198 | # Stores 'best' and 'worst' values of the 199 | # last 'self._funhist_term' generations. 200 | funhist_idx = 2 * (self.generation % self._funhist_term) 201 | self._funhist_values[funhist_idx] = solutions[0][1] 202 | self._funhist_values[funhist_idx + 1] = solutions[-1][1] 203 | 204 | z_k = np.array( 205 | [ 206 | np.linalg.inv(self._sigma * self._B).dot(s[0] - self._mean) 207 | for s in solutions 208 | ] 209 | ) 210 | 211 | # natural gradient estimation in local coordinate 212 | G_delta = np.sum( 213 | [self._weights[i] * z_k[i, :] for i in range(self.population_size)], axis=0 214 | ) 215 | G_M = np.sum( 216 | [ 217 | self._weights[i] 218 | * (np.outer(z_k[i, :], z_k[i, :]) - np.eye(self._n_dim)) 219 | for i in range(self.population_size) 220 | ], 221 | axis=0, 222 | ) 223 | G_sigma = G_M.trace() / self._n_dim 224 | G_B = G_M - G_sigma * np.eye(self._n_dim) 225 | 226 | # parameter update 227 | self._mean += self._eta_mean * self._sigma * np.dot(self._B, G_delta) 228 | self._sigma *= math.exp((self._eta_sigma / 2.0) * G_sigma) 229 | self._B = self._B.dot(_expm((self._eta_B / 2.0) * G_B)) 230 | 231 | def should_stop(self) -> bool: 232 | A = self._B.dot(self._B.T) 233 | A = (A + A.T) / 2 234 | E2, V = np.linalg.eigh(A) 235 | E = np.sqrt(np.where(E2 < 0, _EPS, E2)) 236 | diagA = np.diag(A) 237 | 238 | # Stop if the range of function values of the recent generation is below tolfun. 239 | if ( 240 | self.generation > self._funhist_term 241 | and np.max(self._funhist_values) - np.min(self._funhist_values) 242 | < self._tolfun 243 | ): 244 | return True 245 | 246 | # Stop if detecting divergent behavior. 247 | if self._sigma * np.max(E) > self._tolxup: 248 | return True 249 | 250 | # No effect coordinates: stop if adding 0.2-standard deviations 251 | # in any single coordinate does not change m. 252 | if np.any(self._mean == self._mean + (0.2 * self._sigma * np.sqrt(diagA))): 253 | return True 254 | 255 | # No effect axis: stop if adding 0.1-standard deviation vector in 256 | # any principal axis direction of C does not change m. "pycma" check 257 | # axis one by one at each generation. 258 | i = self.generation % self.dim 259 | if np.all(self._mean == self._mean + (0.1 * self._sigma * E[i] * V[:, i])): 260 | return True 261 | 262 | # Stop if the condition number of the covariance matrix exceeds 1e14. 263 | condition_cov = np.max(E) / np.min(E) 264 | if condition_cov > self._tolconditioncov: 265 | return True 266 | 267 | return False 268 | 269 | 270 | def _is_valid_bounds(bounds: Optional[np.ndarray], mean: np.ndarray) -> bool: 271 | if bounds is None: 272 | return True 273 | if (mean.size, 2) != bounds.shape: 274 | return False 275 | if not np.all(bounds[:, 0] <= mean): 276 | return False 277 | if not np.all(mean <= bounds[:, 1]): 278 | return False 279 | return True 280 | 281 | 282 | def _expm(mat: np.ndarray) -> np.ndarray: 283 | D, U = np.linalg.eigh(mat) 284 | expD = np.exp(D) 285 | return U @ np.diag(expD) @ U.T 286 | -------------------------------------------------------------------------------- /cmaes/_sepcma.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | import numpy as np 5 | 6 | from typing import Any 7 | from typing import cast 8 | from typing import Optional 9 | 10 | 11 | _EPS = 1e-8 12 | _MEAN_MAX = 1e32 13 | _SIGMA_MAX = 1e32 14 | 15 | 16 | class SepCMA: 17 | """Separable CMA-ES stochastic optimizer class with ask-and-tell interface. 18 | 19 | Example: 20 | 21 | .. code:: 22 | 23 | import numpy as np 24 | from cmaes import SepCMA 25 | 26 | def quadratic(x1, x2): 27 | return (x1 - 3) ** 2 + (10 * (x2 + 2)) ** 2 28 | 29 | optimizer = SepCMA(mean=np.zeros(2), sigma=1.3) 30 | 31 | for generation in range(50): 32 | solutions = [] 33 | for _ in range(optimizer.population_size): 34 | # Ask a parameter 35 | x = optimizer.ask() 36 | value = quadratic(x[0], x[1]) 37 | solutions.append((x, value)) 38 | print(f"#{generation} {value} (x1={x[0]}, x2 = {x[1]})") 39 | 40 | # Tell evaluation values. 41 | optimizer.tell(solutions) 42 | 43 | Args: 44 | 45 | mean: 46 | Initial mean vector of multi-variate gaussian distributions. 47 | 48 | sigma: 49 | Initial standard deviation of covariance matrix. 50 | 51 | bounds: 52 | Lower and upper domain boundaries for each parameter (optional). 53 | 54 | n_max_resampling: 55 | A maximum number of resampling parameters (default: 100). 56 | If all sampled parameters are infeasible, the last sampled one 57 | will be clipped with lower and upper bounds. 58 | 59 | seed: 60 | A seed number (optional). 61 | 62 | population_size: 63 | A population size (optional). 64 | """ 65 | 66 | def __init__( 67 | self, 68 | mean: np.ndarray, 69 | sigma: float, 70 | bounds: Optional[np.ndarray] = None, 71 | n_max_resampling: int = 100, 72 | seed: Optional[int] = None, 73 | population_size: Optional[int] = None, 74 | ): 75 | assert sigma > 0, "sigma must be non-zero positive value" 76 | 77 | assert np.all( 78 | np.abs(mean) < _MEAN_MAX 79 | ), f"Abs of all elements of mean vector must be less than {_MEAN_MAX}" 80 | 81 | n_dim = len(mean) 82 | assert n_dim > 1, "The dimension of mean must be larger than 1" 83 | 84 | if population_size is None: 85 | population_size = 4 + math.floor(3 * math.log(n_dim)) # (eq. 48) 86 | assert population_size > 0, "popsize must be non-zero positive value." 87 | 88 | mu = population_size // 2 89 | 90 | # (eq.49) 91 | weights_prime = np.array( 92 | [math.log(mu + 1) - math.log(i + 1) for i in range(mu)] 93 | ) 94 | weights = weights_prime / sum(weights_prime) 95 | mu_eff = 1 / sum(weights**2) 96 | 97 | # learning rate for the rank-one update 98 | alpha_cov = 2 99 | c1 = alpha_cov / ((n_dim + 1.3) ** 2 + mu_eff) 100 | # learning rate for the rank-μ update 101 | cmu_full = 2 / mu_eff / ((n_dim + np.sqrt(2)) ** 2) + (1 - 1 / mu_eff) * min( 102 | 1, (2 * mu_eff - 1) / ((n_dim + 2) ** 2 + mu_eff) 103 | ) 104 | cmu = (n_dim + 2) / 3 * cmu_full 105 | 106 | cm = 1 # (eq. 54) 107 | 108 | # learning rate for the cumulation for the step-size control 109 | c_sigma = (mu_eff + 2) / (n_dim + mu_eff + 3) 110 | d_sigma = 1 + 2 * max(0, math.sqrt((mu_eff - 1) / (n_dim + 1)) - 1) + c_sigma 111 | assert ( 112 | c_sigma < 1 113 | ), "invalid learning rate for cumulation for the step-size control" 114 | 115 | # learning rate for cumulation for the rank-one update 116 | cc = 4 / (n_dim + 4) 117 | assert cc <= 1, "invalid learning rate for cumulation for the rank-one update" 118 | 119 | self._n_dim = n_dim 120 | self._popsize = population_size 121 | self._mu = mu 122 | self._mu_eff = mu_eff 123 | 124 | self._cc = cc 125 | self._c1 = c1 126 | self._cmu = cmu 127 | self._c_sigma = c_sigma 128 | self._d_sigma = d_sigma 129 | self._cm = cm 130 | 131 | # E||N(0, I)|| (p.28) 132 | self._chi_n = math.sqrt(self._n_dim) * ( 133 | 1.0 - (1.0 / (4.0 * self._n_dim)) + 1.0 / (21.0 * (self._n_dim**2)) 134 | ) 135 | 136 | self._weights = weights 137 | 138 | # evolution path 139 | self._p_sigma = np.zeros(n_dim) 140 | self._pc = np.zeros(n_dim) 141 | 142 | self._mean = mean 143 | self._sigma = sigma 144 | self._D: Optional[np.ndarray] = None 145 | self._C: np.ndarray = np.ones(n_dim) 146 | 147 | # bounds contains low and high of each parameter. 148 | assert bounds is None or _is_valid_bounds(bounds, mean), "invalid bounds" 149 | self._bounds = bounds 150 | self._n_max_resampling = n_max_resampling 151 | 152 | self._g = 0 153 | self._rng = np.random.RandomState(seed) 154 | 155 | # Termination criteria 156 | self._tolx = 1e-12 * sigma 157 | self._tolxup = 1e4 158 | self._tolfun = 1e-12 159 | self._tolconditioncov = 1e14 160 | 161 | self._funhist_term = 10 + math.ceil(30 * n_dim / population_size) 162 | self._funhist_values = np.empty(self._funhist_term * 2) 163 | 164 | @property 165 | def dim(self) -> int: 166 | """A number of dimensions""" 167 | return self._n_dim 168 | 169 | @property 170 | def population_size(self) -> int: 171 | """A population size""" 172 | return self._popsize 173 | 174 | @property 175 | def generation(self) -> int: 176 | """Generation number which is monotonically incremented 177 | when multi-variate gaussian distribution is updated.""" 178 | return self._g 179 | 180 | @property 181 | def mean(self) -> np.ndarray: 182 | """Mean Vector""" 183 | return self._mean 184 | 185 | def reseed_rng(self, seed: int) -> None: 186 | self._rng.seed(seed) 187 | 188 | def __getstate__(self) -> dict[str, Any]: 189 | attrs = {} 190 | for name in self.__dict__: 191 | # Remove _rng in pickle serialized object. 192 | if name == "_rng": 193 | continue 194 | attrs[name] = getattr(self, name) 195 | return attrs 196 | 197 | def __setstate__(self, state: dict[str, Any]) -> None: 198 | self.__dict__.update(state) 199 | # Set _rng for unpickled object. 200 | setattr(self, "_rng", np.random.RandomState()) 201 | 202 | def set_bounds(self, bounds: Optional[np.ndarray]) -> None: 203 | """Update boundary constraints""" 204 | assert bounds is None or _is_valid_bounds(bounds, self._mean), "invalid bounds" 205 | self._bounds = bounds 206 | 207 | def ask(self) -> np.ndarray: 208 | """Sample a parameter""" 209 | for i in range(self._n_max_resampling): 210 | x = self._sample_solution() 211 | if self._is_feasible(x): 212 | return x 213 | x = self._sample_solution() 214 | x = self._repair_infeasible_params(x) 215 | return x 216 | 217 | def _eigen_decomposition(self) -> np.ndarray: 218 | if self._D is not None: 219 | return self._D 220 | self._D = np.sqrt(np.where(self._C < 0, _EPS, self._C)) 221 | return self._D 222 | 223 | def _sample_solution(self) -> np.ndarray: 224 | D = self._eigen_decomposition() 225 | z = self._rng.randn(self._n_dim) # ~ N(0, I) 226 | y = D * z # ~ N(0, C) 227 | x = self._mean + self._sigma * y # ~ N(m, σ^2 C) 228 | return x 229 | 230 | def _is_feasible(self, param: np.ndarray) -> bool: 231 | if self._bounds is None: 232 | return True 233 | return cast( 234 | bool, 235 | np.all(param >= self._bounds[:, 0]) and np.all(param <= self._bounds[:, 1]), 236 | ) # Cast bool_ to bool 237 | 238 | def _repair_infeasible_params(self, param: np.ndarray) -> np.ndarray: 239 | if self._bounds is None: 240 | return param 241 | 242 | # clip with lower and upper bound. 243 | param = np.where(param < self._bounds[:, 0], self._bounds[:, 0], param) 244 | param = np.where(param > self._bounds[:, 1], self._bounds[:, 1], param) 245 | return param 246 | 247 | def tell(self, solutions: list[tuple[np.ndarray, float]]) -> None: 248 | """Tell evaluation values""" 249 | 250 | assert len(solutions) == self._popsize, "Must tell popsize-length solutions." 251 | for s in solutions: 252 | assert np.all( 253 | np.abs(s[0]) < _MEAN_MAX 254 | ), f"Abs of all param values must be less than {_MEAN_MAX} to avoid overflow errors" 255 | 256 | self._g += 1 257 | solutions.sort(key=lambda s: s[1]) 258 | 259 | # Stores 'best' and 'worst' values of the 260 | # last 'self._funhist_term' generations. 261 | funhist_idx = 2 * (self.generation % self._funhist_term) 262 | self._funhist_values[funhist_idx] = solutions[0][1] 263 | self._funhist_values[funhist_idx + 1] = solutions[-1][1] 264 | 265 | # Sample new population of search_points, for k=1, ..., popsize 266 | D = self._eigen_decomposition() 267 | self._D = None 268 | 269 | x_k = np.array([s[0] for s in solutions]) # ~ N(m, σ^2 C) 270 | y_k = (x_k - self._mean) / self._sigma # ~ N(0, C) 271 | 272 | # Selection and recombination 273 | y_w = np.sum(y_k[: self._mu].T * self._weights[: self._mu], axis=1) 274 | self._mean += self._cm * self._sigma * y_w 275 | 276 | # Step-size control 277 | self._p_sigma = (1 - self._c_sigma) * self._p_sigma + math.sqrt( 278 | self._c_sigma * (2 - self._c_sigma) * self._mu_eff 279 | ) * (y_w / D) 280 | 281 | norm_p_sigma = np.linalg.norm(self._p_sigma) 282 | self._sigma *= np.exp( 283 | (self._c_sigma / self._d_sigma) * (norm_p_sigma / self._chi_n - 1) 284 | ) 285 | self._sigma = min(self._sigma, _SIGMA_MAX) 286 | 287 | # Covariance matrix adaption 288 | h_sigma_cond_left = norm_p_sigma / math.sqrt( 289 | 1 - (1 - self._c_sigma) ** (2 * (self._g + 1)) 290 | ) 291 | h_sigma_cond_right = (1.4 + 2 / (self._n_dim + 1)) * self._chi_n 292 | h_sigma = 1.0 if h_sigma_cond_left < h_sigma_cond_right else 0.0 # (p.28) 293 | 294 | # (eq.45) 295 | self._pc = (1 - self._cc) * self._pc + h_sigma * math.sqrt( 296 | self._cc * (2 - self._cc) * self._mu_eff 297 | ) * y_w 298 | 299 | delta_h_sigma = (1 - h_sigma) * self._cc * (2 - self._cc) # (p.28) 300 | assert delta_h_sigma <= 1 301 | 302 | # (eq.47) 303 | rank_one = self._pc**2 304 | rank_mu = np.sum( 305 | np.array([w * (y**2) for w, y in zip(self._weights, y_k)]), axis=0 306 | ) 307 | self._C = ( 308 | ( 309 | 1 310 | + self._c1 * delta_h_sigma 311 | - self._c1 312 | - self._cmu * np.sum(self._weights) 313 | ) 314 | * self._C 315 | + self._c1 * rank_one 316 | + self._cmu * rank_mu 317 | ) 318 | 319 | def should_stop(self) -> bool: 320 | D = self._eigen_decomposition() 321 | 322 | # Stop if the range of function values of the recent generation is below tolfun. 323 | if ( 324 | self.generation > self._funhist_term 325 | and np.max(self._funhist_values) - np.min(self._funhist_values) 326 | < self._tolfun 327 | ): 328 | return True 329 | 330 | # Stop if the std of the normal distribution is smaller than tolx 331 | # in all coordinates and pc is smaller than tolx in all components. 332 | if np.all(self._sigma * self._C < self._tolx) and np.all( 333 | self._sigma * self._pc < self._tolx 334 | ): 335 | return True 336 | 337 | # Stop if detecting divergent behavior. 338 | if self._sigma * np.max(D) > self._tolxup: 339 | return True 340 | 341 | # No effect coordinates: stop if adding 0.2-standard deviations 342 | # in any single coordinate does not change m. 343 | if np.any(self._mean == self._mean + (0.2 * self._sigma * np.sqrt(self._C))): 344 | return True 345 | 346 | # No effect axis: stop if adding 0.1-standard deviation vector in 347 | # any principal axis direction of C does not change m. "pycma" check 348 | # axis one by one at each generation. 349 | i = self.generation % self.dim 350 | if np.all( 351 | self._mean == self._mean + (0.1 * self._sigma * D[i] * np.ones(self._n_dim)) 352 | ): 353 | return True 354 | 355 | # Stop if the condition number of the covariance matrix exceeds 1e14. 356 | condition_cov = np.max(D) / np.min(D) 357 | if condition_cov > self._tolconditioncov: 358 | return True 359 | 360 | return False 361 | 362 | 363 | def _is_valid_bounds(bounds: Optional[np.ndarray], mean: np.ndarray) -> bool: 364 | if bounds is None: 365 | return True 366 | if (mean.size, 2) != bounds.shape: 367 | return False 368 | if not np.all(bounds[:, 0] <= mean): 369 | return False 370 | if not np.all(mean <= bounds[:, 1]): 371 | return False 372 | return True 373 | -------------------------------------------------------------------------------- /cmaes/_cmawm.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import numpy as np 5 | 6 | from typing import cast 7 | from typing import Optional 8 | 9 | 10 | from cmaes import CMA 11 | from cmaes._cma import _is_valid_bounds 12 | 13 | try: 14 | from scipy import stats 15 | 16 | chi2_ppf = functools.partial(stats.chi2.ppf, df=1) 17 | norm_cdf = stats.norm.cdf 18 | except ImportError: 19 | from cmaes._stats import chi2_ppf # type: ignore 20 | from cmaes._stats import norm_cdf 21 | 22 | 23 | class CMAwM: 24 | """CMA-ES with Margin class with ask-and-tell interface. 25 | The code is adapted from https://github.com/EvoConJP/CMA-ES_with_Margin. 26 | 27 | Example: 28 | 29 | .. code:: 30 | 31 | import numpy as np 32 | from cmaes import CMAwM 33 | 34 | def ellipsoid_onemax(x, n_zdim): 35 | n = len(x) 36 | n_rdim = n - n_zdim 37 | ellipsoid = sum([(1000 ** (i / (n_rdim - 1)) * x[i]) ** 2 for i in range(n_rdim)]) 38 | onemax = n_zdim - (0. < x[(n - n_zdim):]).sum() 39 | return ellipsoid + 10 * onemax 40 | 41 | binary_dim, continuous_dim = 10, 10 42 | dim = binary_dim + continuous_dim 43 | bounds = np.concatenate( 44 | [ 45 | np.tile([0, 1], (binary_dim, 1)), 46 | np.tile([-np.inf, np.inf], (continuous_dim, 1)), 47 | ] 48 | ) 49 | steps = np.concatenate([np.ones(binary_dim), np.zeros(continuous_dim)]) 50 | optimizer = CMAwM(mean=np.zeros(dim), sigma=2.0, bounds=bounds, steps=steps) 51 | 52 | evals = 0 53 | while True: 54 | solutions = [] 55 | for _ in range(optimizer.population_size): 56 | x_for_eval, x_for_tell = optimizer.ask() 57 | value = ellipsoid_onemax(x_for_eval, binary_dim) 58 | evals += 1 59 | solutions.append((x_for_tell, value)) 60 | optimizer.tell(solutions) 61 | 62 | if optimizer.should_stop(): 63 | break 64 | 65 | Args: 66 | 67 | mean: 68 | Initial mean vector of multi-variate gaussian distributions. 69 | 70 | sigma: 71 | Initial standard deviation of covariance matrix. 72 | 73 | bounds: 74 | Lower and upper domain boundaries for each parameter. 75 | 76 | steps: 77 | Each value represents a step of discretization for each dimension. 78 | Zero (or negative value) means a continuous space. 79 | 80 | n_max_resampling: 81 | A maximum number of resampling parameters (default: 100). 82 | If all sampled parameters are infeasible, the last sampled one 83 | will be clipped with lower and upper bounds. 84 | 85 | seed: 86 | A seed number (optional). 87 | 88 | population_size: 89 | A population size (optional). 90 | 91 | cov: 92 | A covariance matrix (optional). 93 | 94 | margin: 95 | A margin parameter (optional). 96 | """ 97 | 98 | # Paper: https://arxiv.org/abs/2205.13482 99 | 100 | def __init__( 101 | self, 102 | mean: np.ndarray, 103 | sigma: float, 104 | bounds: np.ndarray, 105 | steps: np.ndarray, 106 | n_max_resampling: int = 100, 107 | seed: Optional[int] = None, 108 | population_size: Optional[int] = None, 109 | cov: Optional[np.ndarray] = None, 110 | margin: Optional[float] = None, 111 | ): 112 | # initialize `CMA` 113 | self._cma = CMA( 114 | mean, sigma, bounds, n_max_resampling, seed, population_size, cov 115 | ) 116 | n_dim = self._cma.dim 117 | population_size = self._cma.population_size 118 | self._n_max_resampling = n_max_resampling 119 | 120 | # split discrete space and continuous space 121 | assert len(bounds) == len(steps), "bounds and steps must be the same length" 122 | assert not np.isnan(steps).any(), "steps should not include NaN" 123 | self._discrete_idx = np.where(steps > 0)[0] 124 | discrete_list = [ 125 | np.arange(bounds[i][0], bounds[i][1] + steps[i] / 2, steps[i]) 126 | for i in self._discrete_idx 127 | ] 128 | max_discrete = max([len(discrete) for discrete in discrete_list], default=0) 129 | discrete_space = np.full((len(self._discrete_idx), max_discrete), np.nan) 130 | for i, discrete in enumerate(discrete_list): 131 | discrete_space[i, : len(discrete)] = discrete 132 | 133 | # continuous_space contains low and high of each parameter. 134 | self._continuous_idx = np.where(steps <= 0)[0] 135 | self._continuous_space = bounds[self._continuous_idx] 136 | assert _is_valid_bounds( 137 | self._continuous_space, mean[self._continuous_idx] 138 | ), "invalid bounds" 139 | 140 | # discrete_space 141 | self._n_zdim = len(discrete_space) 142 | if self._n_zdim == 0: 143 | return 144 | self.margin = margin if margin is not None else 1 / (n_dim * population_size) 145 | assert self.margin > 0, "margin must be non-zero positive value." 146 | self.z_space = discrete_space 147 | self.z_lim = (self.z_space[:, 1:] + self.z_space[:, :-1]) / 2 148 | for i in range(self._n_zdim): 149 | self.z_space[i][np.isnan(self.z_space[i])] = np.nanmax(self.z_space[i]) 150 | self.z_lim[i][np.isnan(self.z_lim[i])] = np.nanmax(self.z_lim[i]) 151 | m_z = self._cma._mean[self._discrete_idx] 152 | # m_z_lim_low ->| mean vector |<- m_z_lim_up 153 | m_pos = np.array( 154 | [np.searchsorted(self.z_lim[i], m_z[i]) for i in range(len(m_z))] 155 | ) 156 | z_lim_low_index = np.clip(m_pos - 1, 0, self.z_lim.shape[1] - 1) 157 | z_lim_up_index = np.clip(m_pos, 0, self.z_lim.shape[1] - 1) 158 | self.m_z_lim_low = self.z_lim[np.arange(len(self.z_lim)), z_lim_low_index] 159 | self.m_z_lim_up = self.z_lim[np.arange(len(self.z_lim)), z_lim_up_index] 160 | 161 | self._A = np.full(n_dim, 1.0) 162 | 163 | @property 164 | def dim(self) -> int: 165 | """A number of dimensions""" 166 | return self._cma.dim 167 | 168 | @property 169 | def population_size(self) -> int: 170 | """A population size""" 171 | return self._cma.population_size 172 | 173 | @property 174 | def generation(self) -> int: 175 | """Generation number which is monotonically incremented 176 | when multi-variate gaussian distribution is updated.""" 177 | return self._cma.generation 178 | 179 | @property 180 | def mean(self) -> np.ndarray: 181 | """Mean Vector""" 182 | return self._cma.mean 183 | 184 | @property 185 | def _rng(self) -> np.random.RandomState: 186 | return self._cma._rng 187 | 188 | def reseed_rng(self, seed: int) -> None: 189 | self._cma.reseed_rng(seed) 190 | 191 | def ask(self) -> tuple[np.ndarray, np.ndarray]: 192 | """Sample a parameter and return (i) encoded x and (ii) raw x. 193 | The encoded x is used for the evaluation. 194 | The raw x is used for updating the distribution.""" 195 | for i in range(self._n_max_resampling): 196 | x = self._cma._sample_solution() 197 | if self._is_continuous_feasible(x[self._continuous_idx]): 198 | x_encoded = x.copy() 199 | if self._n_zdim > 0: 200 | x_encoded[self._discrete_idx] = self._encode_discrete_params( 201 | x[self._discrete_idx] 202 | ) 203 | return x_encoded, x 204 | x = self._cma._sample_solution() 205 | x[self._continuous_idx] = self._repair_continuous_params( 206 | x[self._continuous_idx] 207 | ) 208 | x_encoded = x.copy() 209 | if self._n_zdim > 0: 210 | x_encoded[self._discrete_idx] = self._encode_discrete_params( 211 | x[self._discrete_idx] 212 | ) 213 | return x_encoded, x 214 | 215 | def _is_continuous_feasible(self, continuous_param: np.ndarray) -> bool: 216 | if self._continuous_space is None: 217 | return True 218 | return cast( 219 | bool, 220 | np.all(continuous_param >= self._continuous_space[:, 0]) 221 | and np.all(continuous_param <= self._continuous_space[:, 1]), 222 | ) # Cast bool_ to bool. 223 | 224 | def _repair_continuous_params(self, continuous_param: np.ndarray) -> np.ndarray: 225 | if self._continuous_space is None: 226 | return continuous_param 227 | 228 | # clip with lower and upper bound. 229 | param = np.where( 230 | continuous_param < self._continuous_space[:, 0], 231 | self._continuous_space[:, 0], 232 | continuous_param, 233 | ) 234 | param = np.where( 235 | param > self._continuous_space[:, 1], self._continuous_space[:, 1], param 236 | ) 237 | return param 238 | 239 | def _encode_discrete_params(self, discrete_param: np.ndarray) -> np.ndarray: 240 | """Encode the values into discrete domain.""" 241 | mean = self._cma._mean 242 | 243 | x = (discrete_param - mean[self._discrete_idx]) * self._A[ 244 | self._discrete_idx 245 | ] + mean[self._discrete_idx] 246 | x_pos = np.array([np.searchsorted(self.z_lim[i], x[i]) for i in range(len(x))]) 247 | x_enc = self.z_space[np.arange(len(self.z_space)), x_pos] 248 | return x_enc 249 | 250 | def tell(self, solutions: list[tuple[np.ndarray, float]]) -> None: 251 | """Tell evaluation values""" 252 | self._cma.tell(solutions) 253 | mean = self._cma._mean 254 | sigma = self._cma._sigma 255 | C = self._cma._C 256 | 257 | if self._n_zdim == 0: 258 | return 259 | # margin correction 260 | updated_m_integer = mean[self._discrete_idx] 261 | m_pos = np.array( 262 | [ 263 | np.searchsorted(self.z_lim[i], updated_m_integer[i]) 264 | for i in range(len(updated_m_integer)) 265 | ] 266 | ) 267 | z_lim_low_index = np.clip(m_pos - 1, 0, self.z_lim.shape[1] - 1) 268 | z_lim_up_index = np.clip(m_pos, 0, self.z_lim.shape[1] - 1) 269 | self.m_z_lim_low = self.z_lim[np.arange(len(self.z_lim)), z_lim_low_index] 270 | self.m_z_lim_up = self.z_lim[np.arange(len(self.z_lim)), z_lim_up_index] 271 | 272 | # calculate probability low_cdf := Pr(X <= m_z_lim_low) and up_cdf := Pr(m_z_lim_up < X) 273 | # sig_z_sq_Cdiag = self.model.sigma * self.model.A * np.sqrt(np.diag(self.model.C)) 274 | z_scale = ( 275 | sigma 276 | * self._A[self._discrete_idx] 277 | * np.sqrt(np.diag(C)[self._discrete_idx]) 278 | ) 279 | low_cdf = norm_cdf(self.m_z_lim_low, loc=updated_m_integer, scale=z_scale) 280 | up_cdf = 1.0 - norm_cdf(self.m_z_lim_up, loc=updated_m_integer, scale=z_scale) 281 | mid_cdf = 1.0 - (low_cdf + up_cdf) 282 | # edge case 283 | edge_mask = np.maximum(low_cdf, up_cdf) > 0.5 284 | # otherwise 285 | side_mask = np.maximum(low_cdf, up_cdf) <= 0.5 286 | 287 | if np.any(edge_mask): 288 | # modify mask (modify or not) 289 | modify_mask = np.minimum(low_cdf, up_cdf) < self.margin 290 | # modify sign 291 | modify_sign = np.sign(mean[self._discrete_idx] - self.m_z_lim_up) 292 | # distance from m_z_lim_up 293 | dist = ( 294 | sigma 295 | * self._A[self._discrete_idx] 296 | * np.sqrt( 297 | chi2_ppf(q=1.0 - 2.0 * self.margin) * np.diag(C)[self._discrete_idx] 298 | ) 299 | ) 300 | # modify mean vector 301 | mean[self._discrete_idx] = mean[ 302 | self._discrete_idx 303 | ] + modify_mask * edge_mask * ( 304 | self.m_z_lim_up + modify_sign * dist - mean[self._discrete_idx] 305 | ) 306 | 307 | # correct probability 308 | low_cdf = np.maximum(low_cdf, self.margin / 2.0) 309 | up_cdf = np.maximum(up_cdf, self.margin / 2.0) 310 | modified_low_cdf = low_cdf + (1.0 - low_cdf - up_cdf - mid_cdf) * ( 311 | low_cdf - self.margin / 2 312 | ) / (low_cdf + mid_cdf + up_cdf - 3.0 * self.margin / 2) 313 | modified_up_cdf = up_cdf + (1.0 - low_cdf - up_cdf - mid_cdf) * ( 314 | up_cdf - self.margin / 2 315 | ) / (low_cdf + mid_cdf + up_cdf - 3.0 * self.margin / 2) 316 | modified_low_cdf = np.clip(modified_low_cdf, 1e-10, 0.5 - 1e-10) 317 | modified_up_cdf = np.clip(modified_up_cdf, 1e-10, 0.5 - 1e-10) 318 | 319 | # modify mean vector and A (with sigma and C fixed) 320 | chi_low_sq = np.sqrt(chi2_ppf(q=1.0 - 2 * modified_low_cdf)) 321 | chi_up_sq = np.sqrt(chi2_ppf(q=1.0 - 2 * modified_up_cdf)) 322 | C_diag_sq = np.sqrt(np.diag(C))[self._discrete_idx] 323 | 324 | # simultaneous equations 325 | self._A[self._discrete_idx] = self._A[self._discrete_idx] + side_mask * ( 326 | (self.m_z_lim_up - self.m_z_lim_low) 327 | / ((chi_low_sq + chi_up_sq) * sigma * C_diag_sq) 328 | - self._A[self._discrete_idx] 329 | ) 330 | mean[self._discrete_idx] = mean[self._discrete_idx] + side_mask * ( 331 | (self.m_z_lim_low * chi_up_sq + self.m_z_lim_up * chi_low_sq) 332 | / (chi_low_sq + chi_up_sq) 333 | - mean[self._discrete_idx] 334 | ) 335 | 336 | def should_stop(self) -> bool: 337 | return self._cma.should_stop() 338 | -------------------------------------------------------------------------------- /cmaes/_mapcma.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | import numpy as np 5 | 6 | from typing import Any 7 | from typing import cast 8 | from typing import Optional 9 | 10 | 11 | _EPS = 1e-8 12 | _MEAN_MAX = 1e32 13 | _SIGMA_MAX = 1e32 14 | 15 | 16 | class MAPCMA: 17 | """MAP-CMA stochastic optimizer class with ask-and-tell interface. 18 | The only difference from the CMA-ES is the additional term in the mean vector update. 19 | 20 | Example: 21 | 22 | .. code:: 23 | 24 | import numpy as np 25 | from cmaes import MAPCMA 26 | 27 | def quadratic(x1, x2): 28 | return (x1 - 3) ** 2 + (10 * (x2 + 2)) ** 2 29 | 30 | optimizer = MAPCMA(mean=np.zeros(2), sigma=1.3) 31 | 32 | for generation in range(50): 33 | solutions = [] 34 | for _ in range(optimizer.population_size): 35 | # Ask a parameter 36 | x = optimizer.ask() 37 | value = quadratic(x[0], x[1]) 38 | solutions.append((x, value)) 39 | print(f"#{generation} {value} (x1={x[0]}, x2 = {x[1]})") 40 | 41 | # Tell evaluation values. 42 | optimizer.tell(solutions) 43 | 44 | Args: 45 | 46 | mean: 47 | Initial mean vector of multi-variate gaussian distributions. 48 | 49 | sigma: 50 | Initial standard deviation of covariance matrix. 51 | 52 | bounds: 53 | Lower and upper domain boundaries for each parameter (optional). 54 | 55 | n_max_resampling: 56 | A maximum number of resampling parameters (default: 100). 57 | If all sampled parameters are infeasible, the last sampled one 58 | will be clipped with lower and upper bounds. 59 | 60 | seed: 61 | A seed number (optional). 62 | 63 | population_size: 64 | A population size (optional). 65 | 66 | cov: 67 | A covariance matrix (optional). 68 | 69 | momentum_r: 70 | Scaling ratio of momentum update (optional). 71 | """ 72 | 73 | # Paper: https://arxiv.org/abs/2406.16506 74 | 75 | def __init__( 76 | self, 77 | mean: np.ndarray, 78 | sigma: float, 79 | bounds: Optional[np.ndarray] = None, 80 | n_max_resampling: int = 100, 81 | seed: Optional[int] = None, 82 | population_size: Optional[int] = None, 83 | cov: Optional[np.ndarray] = None, 84 | momentum_r: Optional[float] = None, 85 | ): 86 | assert sigma > 0, "sigma must be non-zero positive value" 87 | 88 | assert np.all( 89 | np.abs(mean) < _MEAN_MAX 90 | ), f"Abs of all elements of mean vector must be less than {_MEAN_MAX}" 91 | 92 | n_dim = len(mean) 93 | assert n_dim > 1, "The dimension of mean must be larger than 1" 94 | 95 | if population_size is None: 96 | population_size = 4 + math.floor(3 * math.log(n_dim)) 97 | assert population_size > 0, "popsize must be non-zero positive value." 98 | 99 | mu = population_size // 2 100 | 101 | # MAPCMA uses positive weights, in accordance with the paper 102 | # (CMA uses negative weights) 103 | weights_prime = np.array( 104 | [ 105 | math.log((population_size + 1) / 2) - math.log(i + 1) if i < mu else 0 106 | for i in range(population_size) 107 | ] 108 | ) 109 | weights = weights_prime / weights_prime.sum() 110 | mu_eff = 1 / ((weights**2).sum()) 111 | 112 | # learning rate for the rank-one update 113 | alpha_cov = 2 114 | c1 = alpha_cov / ((n_dim + 1.3) ** 2 + mu_eff) 115 | # learning rate for the rank-μ update 116 | cmu = min( 117 | 1 - c1 - 1e-8, # 1e-8 is for large popsize. 118 | alpha_cov 119 | * (mu_eff - 2 + 1 / mu_eff) 120 | / ((n_dim + 2) ** 2 + alpha_cov * mu_eff / 2), 121 | ) 122 | assert c1 <= 1 - cmu, "invalid learning rate for the rank-one update" 123 | assert cmu <= 1 - c1, "invalid learning rate for the rank-μ update" 124 | 125 | # scaling ratio of momentum update 126 | if momentum_r is None: 127 | momentum_r = n_dim 128 | assert ( 129 | momentum_r > 0 130 | ), "scaling ratio of momentum update must be non-zero positive value." 131 | self._r = momentum_r 132 | 133 | # learning rate for the cumulation for the step-size control 134 | c_sigma = (mu_eff + 2) / (n_dim + mu_eff + 5) 135 | d_sigma = 1 + 2 * max(0, math.sqrt((mu_eff - 1) / (n_dim + 1)) - 1) + c_sigma 136 | assert ( 137 | c_sigma < 1 138 | ), "invalid learning rate for cumulation for the step-size control" 139 | 140 | # learning rate for cumulation for the rank-one update 141 | cc = (4 + mu_eff / n_dim) / (n_dim + 4 + 2 * mu_eff / n_dim) 142 | assert cc <= 1, "invalid learning rate for cumulation for the rank-one update" 143 | 144 | self._n_dim = n_dim 145 | self._popsize = population_size 146 | self._mu = mu 147 | self._mu_eff = mu_eff 148 | 149 | self._cc = cc 150 | self._c1 = c1 151 | self._cmu = cmu 152 | self._c_sigma = c_sigma 153 | self._d_sigma = d_sigma 154 | # ensuring cm + cm * c1 / (r * cmu) = 1 155 | self._cm = 1 / (1 + c1 / (self._r * cmu)) 156 | 157 | # E||N(0, I)|| 158 | self._chi_n = math.sqrt(self._n_dim) * ( 159 | 1.0 - (1.0 / (4.0 * self._n_dim)) + 1.0 / (21.0 * (self._n_dim**2)) 160 | ) 161 | 162 | self._weights = weights 163 | 164 | # evolution path 165 | self._p_sigma = np.zeros(n_dim) 166 | self._pc = np.zeros(n_dim) 167 | 168 | self._mean = mean.copy() 169 | 170 | if cov is None: 171 | self._C = np.eye(n_dim) 172 | else: 173 | assert cov.shape == (n_dim, n_dim), "Invalid shape of covariance matrix" 174 | self._C = cov 175 | 176 | self._sigma = sigma 177 | self._D: Optional[np.ndarray] = None 178 | self._B: Optional[np.ndarray] = None 179 | 180 | # bounds contains low and high of each parameter. 181 | assert bounds is None or _is_valid_bounds(bounds, mean), "invalid bounds" 182 | self._bounds = bounds 183 | self._n_max_resampling = n_max_resampling 184 | 185 | self._g = 0 186 | self._rng = np.random.RandomState(seed) 187 | 188 | # Termination criteria 189 | self._tolx = 1e-12 * sigma 190 | self._tolxup = 1e4 191 | self._tolfun = 1e-12 192 | self._tolconditioncov = 1e14 193 | 194 | self._funhist_term = 10 + math.ceil(30 * n_dim / population_size) 195 | self._funhist_values = np.empty(self._funhist_term * 2) 196 | 197 | def __getstate__(self) -> dict[str, Any]: 198 | attrs = {} 199 | for name in self.__dict__: 200 | # Remove _rng in pickle serialized object. 201 | if name == "_rng": 202 | continue 203 | if name == "_C": 204 | sym1d = _compress_symmetric(self._C) 205 | attrs["_c_1d"] = sym1d 206 | continue 207 | attrs[name] = getattr(self, name) 208 | return attrs 209 | 210 | def __setstate__(self, state: dict[str, Any]) -> None: 211 | state["_C"] = _decompress_symmetric(state["_c_1d"]) 212 | del state["_c_1d"] 213 | self.__dict__.update(state) 214 | # Set _rng for unpickled object. 215 | setattr(self, "_rng", np.random.RandomState()) 216 | 217 | @property 218 | def dim(self) -> int: 219 | """A number of dimensions""" 220 | return self._n_dim 221 | 222 | @property 223 | def population_size(self) -> int: 224 | """A population size""" 225 | return self._popsize 226 | 227 | @property 228 | def generation(self) -> int: 229 | """Generation number which is monotonically incremented 230 | when multi-variate gaussian distribution is updated.""" 231 | return self._g 232 | 233 | @property 234 | def mean(self) -> np.ndarray: 235 | """Mean Vector""" 236 | return self._mean 237 | 238 | def reseed_rng(self, seed: int) -> None: 239 | self._rng.seed(seed) 240 | 241 | def set_bounds(self, bounds: Optional[np.ndarray]) -> None: 242 | """Update boundary constraints""" 243 | assert bounds is None or _is_valid_bounds(bounds, self._mean), "invalid bounds" 244 | self._bounds = bounds 245 | 246 | def ask(self) -> np.ndarray: 247 | """Sample a parameter""" 248 | for i in range(self._n_max_resampling): 249 | x = self._sample_solution() 250 | if self._is_feasible(x): 251 | return x 252 | x = self._sample_solution() 253 | x = self._repair_infeasible_params(x) 254 | return x 255 | 256 | def _eigen_decomposition(self) -> tuple[np.ndarray, np.ndarray]: 257 | if self._B is not None and self._D is not None: 258 | return self._B, self._D 259 | 260 | self._C = (self._C + self._C.T) / 2 261 | D2, B = np.linalg.eigh(self._C) 262 | D = np.sqrt(np.where(D2 < 0, _EPS, D2)) 263 | self._C = np.dot(np.dot(B, np.diag(D**2)), B.T) 264 | 265 | self._B, self._D = B, D 266 | return B, D 267 | 268 | def _sample_solution(self) -> np.ndarray: 269 | B, D = self._eigen_decomposition() 270 | z = self._rng.randn(self._n_dim) # ~ N(0, I) 271 | y = cast(np.ndarray, B.dot(np.diag(D))).dot(z) # ~ N(0, C) 272 | x = self._mean + self._sigma * y # ~ N(m, σ^2 C) 273 | return x 274 | 275 | def _is_feasible(self, param: np.ndarray) -> bool: 276 | if self._bounds is None: 277 | return True 278 | return cast( 279 | bool, 280 | np.all(param >= self._bounds[:, 0]) and np.all(param <= self._bounds[:, 1]), 281 | ) # Cast bool_ to bool. 282 | 283 | def _repair_infeasible_params(self, param: np.ndarray) -> np.ndarray: 284 | if self._bounds is None: 285 | return param 286 | 287 | # clip with lower and upper bound. 288 | param = np.where(param < self._bounds[:, 0], self._bounds[:, 0], param) 289 | param = np.where(param > self._bounds[:, 1], self._bounds[:, 1], param) 290 | return param 291 | 292 | def tell(self, solutions: list[tuple[np.ndarray, float]]) -> None: 293 | """Tell evaluation values""" 294 | 295 | assert len(solutions) == self._popsize, "Must tell popsize-length solutions." 296 | for s in solutions: 297 | assert np.all( 298 | np.abs(s[0]) < _MEAN_MAX 299 | ), f"Abs of all param values must be less than {_MEAN_MAX} to avoid overflow errors" 300 | 301 | self._g += 1 302 | solutions.sort(key=lambda s: s[1]) 303 | 304 | # Stores 'best' and 'worst' values of the 305 | # last 'self._funhist_term' generations. 306 | funhist_idx = 2 * (self.generation % self._funhist_term) 307 | self._funhist_values[funhist_idx] = solutions[0][1] 308 | self._funhist_values[funhist_idx + 1] = solutions[-1][1] 309 | 310 | # Sample new population of search_points, for k=1, ..., popsize 311 | B, D = self._eigen_decomposition() 312 | self._B, self._D = None, None 313 | 314 | x_k = np.array([s[0] for s in solutions]) # ~ N(m, σ^2 C) 315 | y_k = (x_k - self._mean) / self._sigma # ~ N(0, C) 316 | 317 | # Selection and recombination 318 | y_w = np.sum(y_k.T * self._weights, axis=1) 319 | 320 | # Evolution paths 321 | # MAP-CMA does not employ the Heaviside function h_sigma for simplifying the update rules. 322 | C_2 = cast( 323 | np.ndarray, cast(np.ndarray, B.dot(np.diag(1 / D))).dot(B.T) 324 | ) # C^(-1/2) = B D^(-1) B^T 325 | self._p_sigma = (1 - self._c_sigma) * self._p_sigma + math.sqrt( 326 | self._c_sigma * (2 - self._c_sigma) * self._mu_eff 327 | ) * C_2.dot(y_w) 328 | 329 | self._pc = (1 - self._cc) * self._pc + math.sqrt( 330 | self._cc * (2 - self._cc) * self._mu_eff 331 | ) * y_w 332 | 333 | # Mean vector update (rank-μ + momentum update) 334 | self._mean += self._cm * ( 335 | self._sigma * y_w + self._c1 / self._r / self._cmu * self._sigma * self._pc 336 | ) 337 | 338 | # Covariance matrix adaption 339 | rank_one = np.outer(self._pc, self._pc) 340 | rank_mu = np.sum( 341 | np.array([w * np.outer(y, y) for w, y in zip(self._weights, y_k)]), axis=0 342 | ) 343 | self._C = ( 344 | (1 - self._c1 - self._cmu * np.sum(self._weights)) * self._C 345 | + self._c1 * rank_one 346 | + self._cmu * rank_mu 347 | ) 348 | 349 | # Step-size control 350 | norm_p_sigma = np.linalg.norm(self._p_sigma) 351 | self._sigma *= np.exp( 352 | (self._c_sigma / self._d_sigma) * (norm_p_sigma / self._chi_n - 1) 353 | ) 354 | self._sigma = min(self._sigma, _SIGMA_MAX) 355 | 356 | def should_stop(self) -> bool: 357 | B, D = self._eigen_decomposition() 358 | dC = np.diag(self._C) 359 | 360 | # Stop if the range of function values of the recent generation is below tolfun. 361 | if ( 362 | self.generation > self._funhist_term 363 | and np.max(self._funhist_values) - np.min(self._funhist_values) 364 | < self._tolfun 365 | ): 366 | return True 367 | 368 | # Stop if the std of the normal distribution is smaller than tolx 369 | # in all coordinates and pc is smaller than tolx in all components. 370 | if np.all(self._sigma * dC < self._tolx) and np.all( 371 | self._sigma * self._pc < self._tolx 372 | ): 373 | return True 374 | 375 | # Stop if detecting divergent behavior. 376 | if self._sigma * np.max(D) > self._tolxup: 377 | return True 378 | 379 | # No effect coordinates: stop if adding 0.2-standard deviations 380 | # in any single coordinate does not change m. 381 | if np.any(self._mean == self._mean + (0.2 * self._sigma * np.sqrt(dC))): 382 | return True 383 | 384 | # No effect axis: stop if adding 0.1-standard deviation vector in 385 | # any principal axis direction of C does not change m. "pycma" check 386 | # axis one by one at each generation. 387 | i = self.generation % self.dim 388 | if np.all(self._mean == self._mean + (0.1 * self._sigma * D[i] * B[:, i])): 389 | return True 390 | 391 | # Stop if the condition number of the covariance matrix exceeds 1e14. 392 | condition_cov = np.max(D) / np.min(D) 393 | if condition_cov > self._tolconditioncov: 394 | return True 395 | 396 | return False 397 | 398 | 399 | def _is_valid_bounds(bounds: Optional[np.ndarray], mean: np.ndarray) -> bool: 400 | if bounds is None: 401 | return True 402 | if (mean.size, 2) != bounds.shape: 403 | return False 404 | if not np.all(bounds[:, 0] <= mean): 405 | return False 406 | if not np.all(mean <= bounds[:, 1]): 407 | return False 408 | return True 409 | 410 | 411 | def _compress_symmetric(sym2d: np.ndarray) -> np.ndarray: 412 | assert len(sym2d.shape) == 2 and sym2d.shape[0] == sym2d.shape[1] 413 | n = sym2d.shape[0] 414 | dim = (n * (n + 1)) // 2 415 | sym1d = np.zeros(dim) 416 | start = 0 417 | for i in range(n): 418 | sym1d[start : start + n - i] = sym2d[i][i:] # noqa: E203 419 | start += n - i 420 | return sym1d 421 | 422 | 423 | def _decompress_symmetric(sym1d: np.ndarray) -> np.ndarray: 424 | n = int(np.sqrt(sym1d.size * 2)) 425 | assert (n * (n + 1)) // 2 == sym1d.size 426 | R, C = np.triu_indices(n) 427 | out = np.zeros((n, n), dtype=sym1d.dtype) 428 | out[R, C] = sym1d 429 | out[C, R] = sym1d 430 | return out 431 | -------------------------------------------------------------------------------- /cmaes/_dxnesic.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | import sys 5 | 6 | import numpy as np 7 | 8 | from typing import cast 9 | from typing import Optional 10 | 11 | 12 | _EPS = 1e-8 13 | _MEAN_MAX = 1e32 14 | _SIGMA_MAX = 1e32 15 | 16 | 17 | class DXNESIC: 18 | """DX-NES-IC stochastic optimizer class with ask-and-tell interface. 19 | 20 | Example: 21 | 22 | .. code:: 23 | 24 | import numpy as np 25 | from cmaes import DXNESIC 26 | 27 | def quadratic(x1, x2): 28 | return (x1 - 3) ** 2 + (10 * (x2 + 2)) ** 2 29 | 30 | optimizer = DXNESIC(mean=np.zeros(2), sigma=1.3) 31 | 32 | for generation in range(50): 33 | solutions = [] 34 | for _ in range(optimizer.population_size): 35 | # Ask a parameter 36 | x = optimizer.ask() 37 | value = quadratic(x[0], x[1]) 38 | solutions.append((x, value)) 39 | print(f"#{generation} {value} (x1={x[0]}, x2 = {x[1]})") 40 | 41 | # Tell evaluation values. 42 | optimizer.tell(solutions) 43 | 44 | Args: 45 | 46 | mean: 47 | Initial mean vector of multi-variate gaussian distributions. 48 | 49 | sigma: 50 | Initial standard deviation of covariance matrix. 51 | 52 | bounds: 53 | Lower and upper domain boundaries for each parameter (optional). 54 | 55 | n_max_resampling: 56 | A maximum number of resampling parameters (default: 100). 57 | If all sampled parameters are infeasible, the last sampled one 58 | will be clipped with lower and upper bounds. 59 | 60 | seed: 61 | A seed number (optional). 62 | 63 | population_size: 64 | A population size (optional). 65 | 66 | cov: 67 | A covariance matrix (optional). 68 | """ 69 | 70 | # Paper: https://ieeexplore.ieee.org/abstract/document/9504865 71 | 72 | def __init__( 73 | self, 74 | mean: np.ndarray, 75 | sigma: float, 76 | bounds: Optional[np.ndarray] = None, 77 | n_max_resampling: int = 100, 78 | seed: Optional[int] = None, 79 | population_size: Optional[int] = None, 80 | ): 81 | assert sigma > 0, "sigma must be non-zero positive value" 82 | 83 | assert np.all( 84 | np.abs(mean) < _MEAN_MAX 85 | ), f"Abs of all elements of mean vector must be less than {_MEAN_MAX}" 86 | 87 | n_dim = len(mean) 88 | assert n_dim > 1, "The dimension of mean must be larger than 1" 89 | 90 | if population_size is None: 91 | population_size = 4 + math.floor(3 * math.log(n_dim)) 92 | assert population_size > 0, "popsize must be non-zero positive value." 93 | 94 | w_rank_hat = np.log(population_size / 2 + 1) - np.log( 95 | np.arange(1, population_size + 1) 96 | ) 97 | w_rank_hat[np.where(w_rank_hat < 0)] = 0 98 | w_rank = w_rank_hat / sum(w_rank_hat) - (1.0 / population_size) 99 | mu_eff = 1 / sum((w_rank + (1.0 / population_size)) ** 2) 100 | 101 | # learning rate for the cumulation for the step-size control 102 | c_sigma = (mu_eff + 2) / (n_dim + mu_eff + 5) 103 | assert ( 104 | c_sigma < 1 105 | ), "invalid learning rate for cumulation for the step-size control" 106 | 107 | # distance weight parameter 108 | h_inv = _get_h_inv(n_dim) 109 | 110 | self._n_dim = n_dim 111 | self._popsize = population_size 112 | self._mu_eff = mu_eff 113 | 114 | self._h_inv = h_inv 115 | self._c_sigma = c_sigma 116 | 117 | # E||N(0, I)|| 118 | self._chi_n = math.sqrt(self._n_dim) * ( 119 | 1.0 - (1.0 / (4.0 * self._n_dim)) + 1.0 / (21.0 * (self._n_dim**2)) 120 | ) 121 | 122 | # weights 123 | self._w_rank = w_rank 124 | self._w_rank_hat = w_rank_hat 125 | 126 | # for antithetic sampling 127 | self._zsym: Optional[np.ndarray] = None 128 | 129 | # learning rate 130 | self._eta_mean = 1.0 131 | self._eta_move_sigma = 1.0 132 | self._c_gamma = 1.0 / (3.0 * (n_dim - 1.0)) 133 | self._d_gamma = min(1.0, n_dim / population_size) 134 | self._gamma = 1.0 135 | 136 | # evolution path 137 | self._p_sigma = np.zeros(n_dim) 138 | 139 | # distribution parameter 140 | self._mean = mean.copy() 141 | self._sigma = sigma 142 | self._B = np.eye(n_dim) 143 | 144 | # bounds contains low and high of each parameter. 145 | assert bounds is None or _is_valid_bounds(bounds, mean), "invalid bounds" 146 | self._bounds = bounds 147 | self._n_max_resampling = n_max_resampling 148 | 149 | self._g = 0 150 | self._rng = np.random.RandomState(seed) 151 | 152 | # Termination criteria 153 | self._tolx = 1e-12 * sigma 154 | self._tolxup = 1e4 155 | self._tolfun = 1e-12 156 | self._tolconditioncov = 1e14 157 | 158 | self._funhist_term = 10 + math.ceil(30 * n_dim / population_size) 159 | self._funhist_values = np.empty(self._funhist_term * 2) 160 | 161 | @property 162 | def dim(self) -> int: 163 | """A number of dimensions""" 164 | return self._n_dim 165 | 166 | @property 167 | def population_size(self) -> int: 168 | """A population size""" 169 | return self._popsize 170 | 171 | @property 172 | def generation(self) -> int: 173 | """Generation number which is monotonically incremented 174 | when multi-variate gaussian distribution is updated.""" 175 | return self._g 176 | 177 | def _alpha_dist(self, num_feasible: int) -> float: 178 | return ( 179 | self._h_inv 180 | * min(1.0, math.sqrt(float(self._popsize) / self._n_dim)) 181 | * math.sqrt(float(num_feasible) / self._popsize) 182 | ) 183 | 184 | def _w_dist_hat(self, z: np.ndarray, num_feasible: int) -> float: 185 | return math.exp(self._alpha_dist(num_feasible) * np.linalg.norm(z)) 186 | 187 | def _eta_stag_sigma(self, num_feasible: int) -> float: 188 | return math.tanh( 189 | (0.024 * num_feasible + 0.7 * self._n_dim + 20.0) / (self._n_dim + 12.0) 190 | ) 191 | 192 | def _eta_conv_sigma(self, num_feasible: int) -> float: 193 | return 2.0 * math.tanh( 194 | (0.025 * num_feasible + 0.75 * self._n_dim + 10.0) / (self._n_dim + 4.0) 195 | ) 196 | 197 | def _eta_move_B(self, num_feasible: int) -> float: 198 | return ( 199 | 180 200 | * self._n_dim 201 | * math.tanh(0.02 * num_feasible) 202 | / (47 * (self._n_dim**2) + 6400) 203 | ) 204 | 205 | def _eta_stag_B(self, num_feasible: int) -> float: 206 | return ( 207 | 168 208 | * self._n_dim 209 | * math.tanh(0.02 * num_feasible) 210 | / (47 * (self._n_dim**2) + 6400) 211 | ) 212 | 213 | def _eta_conv_B(self, num_feasible: int) -> float: 214 | return ( 215 | 12 216 | * self._n_dim 217 | * math.tanh(0.02 * num_feasible) 218 | / (47 * (self._n_dim**2) + 6400) 219 | ) 220 | 221 | def reseed_rng(self, seed: int) -> None: 222 | self._rng.seed(seed) 223 | 224 | def set_bounds(self, bounds: Optional[np.ndarray]) -> None: 225 | """Update boundary constraints""" 226 | assert bounds is None or _is_valid_bounds(bounds, self._mean), "invalid bounds" 227 | self._bounds = bounds 228 | 229 | def ask(self) -> np.ndarray: 230 | """Sample a parameter""" 231 | for i in range(self._n_max_resampling): 232 | x = self._sample_solution() 233 | if self._is_feasible(x): 234 | return x 235 | x = self._sample_solution() 236 | x = self._repair_infeasible_params(x) 237 | return x 238 | 239 | def _sample_solution(self) -> np.ndarray: 240 | # antithetic sampling 241 | if self._zsym is None: 242 | z = self._rng.randn(self._n_dim) # ~ N(0, I) 243 | self._zsym = z 244 | else: 245 | z = -self._zsym 246 | self._zsym = None 247 | x = self._mean + self._sigma * self._B.dot(z) # ~ N(m, σ^2 B B^T) 248 | return x 249 | 250 | def _is_feasible(self, param: np.ndarray) -> bool: 251 | if self._bounds is None: 252 | return True 253 | return cast( 254 | bool, 255 | np.all(param >= self._bounds[:, 0]) and np.all(param <= self._bounds[:, 1]), 256 | ) # Cast bool_ to bool. 257 | 258 | def _repair_infeasible_params(self, param: np.ndarray) -> np.ndarray: 259 | if self._bounds is None: 260 | return param 261 | 262 | # clip with lower and upper bound. 263 | param = np.where(param < self._bounds[:, 0], self._bounds[:, 0], param) 264 | param = np.where(param > self._bounds[:, 1], self._bounds[:, 1], param) 265 | return param 266 | 267 | def tell(self, solutions: list[tuple[np.ndarray, float]]) -> None: 268 | """Tell evaluation values""" 269 | 270 | assert len(solutions) == self._popsize, "Must tell popsize-length solutions." 271 | for s in solutions: 272 | assert np.all( 273 | np.abs(s[0]) < _MEAN_MAX 274 | ), f"Abs of all param values must be less than {_MEAN_MAX} to avoid overflow errors" 275 | 276 | # counting # feasible solutions 277 | lamb_feas = len([s[1] for s in solutions if s[1] < sys.maxsize]) 278 | 279 | self._g += 1 280 | solutions.sort(key=lambda s: s[1]) 281 | 282 | # Stores 'best' and 'worst' values of the 283 | # last 'self._funhist_term' generations. 284 | funhist_idx = 2 * (self.generation % self._funhist_term) 285 | self._funhist_values[funhist_idx] = solutions[0][1] 286 | self._funhist_values[funhist_idx + 1] = solutions[-1][1] 287 | 288 | z_k = np.array( 289 | [ 290 | np.linalg.inv(self._sigma * self._B).dot(s[0] - self._mean) 291 | for s in solutions 292 | ] 293 | ) 294 | 295 | # Evolution path 296 | z_w = np.sum(z_k.T * self._w_rank, axis=1) 297 | self._p_sigma = (1 - self._c_sigma) * self._p_sigma + math.sqrt( 298 | self._c_sigma * (2 - self._c_sigma) * self._mu_eff 299 | ) * z_w 300 | 301 | norm_p_sigma = np.linalg.norm(self._p_sigma) 302 | 303 | # switching learning rate depending on search situation 304 | movement_phase = norm_p_sigma >= self._chi_n 305 | 306 | # distance weight 307 | w_dist_tmp = np.array( 308 | [ 309 | self._w_rank_hat[i] * self._w_dist_hat(z_k[i, :], lamb_feas) 310 | for i in range(self.population_size) 311 | ] 312 | ) 313 | w_dist = w_dist_tmp / sum(w_dist_tmp) - 1.0 / self.population_size 314 | 315 | # switching weights and learning rate 316 | w = w_dist if movement_phase else self._w_rank 317 | eta_sigma = ( 318 | self._eta_move_sigma 319 | if norm_p_sigma >= self._chi_n 320 | else ( 321 | self._eta_stag_sigma(lamb_feas) 322 | if norm_p_sigma >= 0.1 * self._chi_n 323 | else self._eta_conv_sigma(lamb_feas) 324 | ) 325 | ) 326 | eta_B = ( 327 | self._eta_move_B(lamb_feas) 328 | if norm_p_sigma >= self._chi_n 329 | else ( 330 | self._eta_stag_B(lamb_feas) 331 | if norm_p_sigma >= 0.1 * self._chi_n 332 | else self._eta_conv_B(lamb_feas) 333 | ) 334 | ) 335 | 336 | # natural gradient estimation in local coordinate 337 | G_delta = np.sum( 338 | [w[i] * z_k[i, :] for i in range(self.population_size)], axis=0 339 | ) 340 | G_M = np.sum( 341 | [ 342 | w[i] * (np.outer(z_k[i, :], z_k[i, :]) - np.eye(self._n_dim)) 343 | for i in range(self.population_size) 344 | ], 345 | axis=0, 346 | ) 347 | G_sigma = G_M.trace() / self._n_dim 348 | G_B = G_M - G_sigma * np.eye(self._n_dim) 349 | 350 | # parameter update 351 | bBBT = self._B @ self._B.T 352 | self._mean += self._eta_mean * self._sigma * np.dot(self._B, G_delta) 353 | self._sigma *= math.exp((eta_sigma / 2.0) * G_sigma) 354 | # self._B = self._B.dot(expm((eta_B / 2.0) * G_B)) 355 | self._B = self._B.dot(_expm((eta_B / 2.0) * G_B)) 356 | aBBT = self._B @ self._B.T 357 | 358 | # emphasizing expansion 359 | e, v = np.linalg.eigh(bBBT) 360 | tau_vec = [ 361 | (v[:, i].reshape(self._n_dim, 1).T @ aBBT @ v[:, i].reshape(self._n_dim, 1)) 362 | / ( 363 | v[:, i].reshape(self._n_dim, 1).T 364 | @ bBBT 365 | @ v[:, i].reshape(self._n_dim, 1) 366 | ) 367 | - 1 368 | for i in range(self._n_dim) 369 | ] 370 | flg_tau = [1.0 if tau_vec[i] > 0 else 0.0 for i in range(self._n_dim)] 371 | tau = max(tau_vec) 372 | gamma = max( 373 | (1.0 - self._c_gamma) * self._gamma 374 | + self._c_gamma * math.sqrt(1.0 + self._d_gamma * tau), 375 | 1.0, 376 | ) 377 | if movement_phase: 378 | Q = (gamma - 1.0) * np.sum( 379 | [flg_tau[i] * np.outer(v[:, i], v[:, i]) for i in range(self._n_dim)], 380 | axis=0, 381 | ) + np.eye(self._n_dim) 382 | stepQ = math.pow(np.linalg.det(Q), 1.0 / self._n_dim) 383 | self._sigma *= stepQ 384 | self._B = Q @ self._B / stepQ 385 | 386 | def should_stop(self) -> bool: 387 | A = self._B.dot(self._B.T) 388 | A = (A + A.T) / 2 389 | E2, V = np.linalg.eigh(A) 390 | E = np.sqrt(np.where(E2 < 0, _EPS, E2)) 391 | diagA = np.diag(A) 392 | 393 | # Stop if the range of function values of the recent generation is below tolfun. 394 | if ( 395 | self.generation > self._funhist_term 396 | and np.max(self._funhist_values) - np.min(self._funhist_values) 397 | < self._tolfun 398 | ): 399 | return True 400 | 401 | # Stop if detecting divergent behavior. 402 | if self._sigma * np.max(E) > self._tolxup: 403 | return True 404 | 405 | # No effect coordinates: stop if adding 0.2-standard deviations 406 | # in any single coordinate does not change m. 407 | if np.any(self._mean == self._mean + (0.2 * self._sigma * np.sqrt(diagA))): 408 | return True 409 | 410 | # No effect axis: stop if adding 0.1-standard deviation vector in 411 | # any principal axis direction of C does not change m. "pycma" check 412 | # axis one by one at each generation. 413 | i = self.generation % self.dim 414 | if np.all(self._mean == self._mean + (0.1 * self._sigma * E[i] * V[:, i])): 415 | return True 416 | 417 | # Stop if the condition number of the covariance matrix exceeds 1e14. 418 | condition_cov = np.max(E) / np.min(E) 419 | if condition_cov > self._tolconditioncov: 420 | return True 421 | 422 | return False 423 | 424 | 425 | def _is_valid_bounds(bounds: Optional[np.ndarray], mean: np.ndarray) -> bool: 426 | if bounds is None: 427 | return True 428 | if (mean.size, 2) != bounds.shape: 429 | return False 430 | if not np.all(bounds[:, 0] <= mean): 431 | return False 432 | if not np.all(mean <= bounds[:, 1]): 433 | return False 434 | return True 435 | 436 | 437 | def _get_h_inv(dim: int) -> float: 438 | def f(a: float) -> float: 439 | return ((1.0 + a * a) * math.exp(a * a / 2.0) / 0.24) - 10.0 - dim 440 | 441 | def f_prime(a: float) -> float: 442 | return (1.0 / 0.24) * a * math.exp(a * a / 2.0) * (3.0 + a * a) 443 | 444 | h_inv = 6.0 445 | while abs(f(h_inv)) > 1e-10: 446 | last = h_inv 447 | h_inv = h_inv - 0.5 * (f(h_inv) / f_prime(h_inv)) 448 | if abs(h_inv - last) < 1e-16: 449 | # Exit early since no further improvements are happening 450 | break 451 | return h_inv 452 | 453 | 454 | def _expm(mat: np.ndarray) -> np.ndarray: 455 | D, U = np.linalg.eigh(mat) 456 | expD = np.exp(D) 457 | return U @ np.diag(expD) @ U.T 458 | -------------------------------------------------------------------------------- /cmaes/_catcma.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | import numpy as np 5 | 6 | from typing import Any 7 | from typing import cast 8 | from typing import Optional 9 | 10 | 11 | _EPS = 1e-8 12 | _MEAN_MAX = 1e32 13 | _SIGMA_MAX = 1e32 14 | 15 | 16 | class CatCMA: 17 | """CatCMA stochastic optimizer class with ask-and-tell interface. 18 | 19 | Example: 20 | 21 | .. code:: 22 | 23 | import numpy as np 24 | from cmaes import CatCMA 25 | 26 | def sphere_com(x, c): 27 | return sum(x*x) + len(c) - sum(c[:,0]) 28 | 29 | optimizer = CatCMA(mean=3 * np.ones(3), sigma=2.0, cat_num=np.array([3, 3, 3])) 30 | 31 | for generation in range(50): 32 | solutions = [] 33 | for _ in range(optimizer.population_size): 34 | # Ask a parameter 35 | x, c = optimizer.ask() 36 | value = sphere_com(x, c) 37 | solutions.append(((x, c), value)) 38 | print(f"#{generation} {value}") 39 | 40 | # Tell evaluation values. 41 | optimizer.tell(solutions) 42 | 43 | Args: 44 | 45 | mean: 46 | Initial mean vector of multivariate gaussian distribution. 47 | 48 | sigma: 49 | Initial standard deviation of covariance matrix. 50 | 51 | cat_num: 52 | Numbers of categories. 53 | 54 | bounds: 55 | Lower and upper domain boundaries for each parameter (optional). 56 | 57 | n_max_resampling: 58 | A maximum number of resampling parameters (default: 100). 59 | If all sampled parameters are infeasible, the last sampled one 60 | will be clipped with lower and upper bounds. 61 | 62 | seed: 63 | A seed number (optional). 64 | 65 | population_size: 66 | A population size (optional). 67 | 68 | cov: 69 | A covariance matrix (optional). 70 | 71 | cat_param: 72 | A parameter of categorical distribution (optional). 73 | 74 | margin: 75 | A margin (lower bound) of categorical distribution (optional). 76 | 77 | min_eigenvalue: 78 | Lower bound of eigenvalue of multivariate Gaussian distribution (optional). 79 | """ 80 | 81 | # Paper: https://arxiv.org/abs/2405.09962 82 | 83 | def __init__( 84 | self, 85 | mean: np.ndarray, 86 | sigma: float, 87 | cat_num: np.ndarray, 88 | bounds: Optional[np.ndarray] = None, 89 | n_max_resampling: int = 100, 90 | seed: Optional[int] = None, 91 | population_size: Optional[int] = None, 92 | cov: Optional[np.ndarray] = None, 93 | cat_param: Optional[np.ndarray] = None, 94 | margin: Optional[np.ndarray] = None, 95 | min_eigenvalue: Optional[float] = None, 96 | ): 97 | assert sigma > 0, "sigma must be non-zero positive value" 98 | 99 | assert np.all( 100 | np.abs(mean) < _MEAN_MAX 101 | ), f"Abs of all elements of mean vector must be less than {_MEAN_MAX}" 102 | 103 | self._n_co = len(mean) 104 | self._n_ca = len(cat_num) 105 | self._n = self._n_co + self._n_ca 106 | assert self._n_co > 1, "The dimension of mean must be larger than 1" 107 | assert self._n_ca > 0, "The dimension of categorical variable must be positive" 108 | assert np.all(cat_num > 1), "The number of categories must be larger than 1" 109 | 110 | if population_size is None: 111 | population_size = 4 + math.floor(3 * math.log(self._n)) 112 | assert population_size > 0, "popsize must be non-zero positive value." 113 | 114 | mu = population_size // 2 115 | 116 | # CatCMA assumes that the weights of the lower half are zero. 117 | # (CMA uses negative weights while CatCMA uses positive weights.) 118 | weights_prime = np.array( 119 | [ 120 | math.log((population_size + 1) / 2) - math.log(i + 1) if i < mu else 0 121 | for i in range(population_size) 122 | ] 123 | ) 124 | weights = weights_prime / weights_prime.sum() 125 | mu_eff = 1 / ((weights**2).sum()) 126 | 127 | # learning rate for the rank-one update 128 | alpha_cov = 2 129 | c1 = alpha_cov / ((self._n_co + 1.3) ** 2 + mu_eff) 130 | # learning rate for the rank-μ update 131 | cmu = min( 132 | 1 - c1 - 1e-8, # 1e-8 is for large popsize. 133 | alpha_cov 134 | * (mu_eff - 2 + 1 / mu_eff) 135 | / ((self._n_co + 2) ** 2 + alpha_cov * mu_eff / 2), 136 | ) 137 | assert c1 <= 1 - cmu, "invalid learning rate for the rank-one update" 138 | assert cmu <= 1 - c1, "invalid learning rate for the rank-μ update" 139 | 140 | cm = 1 141 | 142 | # learning rate for the cumulation for the step-size control 143 | c_sigma = (mu_eff + 2) / (self._n_co + mu_eff + 5) 144 | d_sigma = ( 145 | 1 + 2 * max(0, math.sqrt((mu_eff - 1) / (self._n_co + 1)) - 1) + c_sigma 146 | ) 147 | assert ( 148 | c_sigma < 1 149 | ), "invalid learning rate for cumulation for the step-size control" 150 | 151 | # learning rate for cumulation for the rank-one update 152 | cc = (4 + mu_eff / self._n_co) / (self._n_co + 4 + 2 * mu_eff / self._n_co) 153 | assert cc <= 1, "invalid learning rate for cumulation for the rank-one update" 154 | 155 | self._popsize = population_size 156 | self._mu = mu 157 | self._mu_eff = mu_eff 158 | 159 | self._cc = cc 160 | self._c1 = c1 161 | self._cmu = cmu 162 | self._c_sigma = c_sigma 163 | self._d_sigma = d_sigma 164 | self._cm = cm 165 | 166 | # E||N(0, I)|| 167 | self._chi_n = math.sqrt(self._n_co) * ( 168 | 1.0 - (1.0 / (4.0 * self._n_co)) + 1.0 / (21.0 * (self._n_co**2)) 169 | ) 170 | 171 | self._weights = weights 172 | 173 | # evolution path 174 | self._p_sigma = np.zeros(self._n_co) 175 | self._pc = np.zeros(self._n_co) 176 | 177 | self._mean = mean.copy() 178 | 179 | if cov is None: 180 | self._C = np.eye(self._n_co) 181 | else: 182 | assert cov.shape == ( 183 | self._n_co, 184 | self._n_co, 185 | ), "Invalid shape of covariance matrix" 186 | self._C = cov 187 | 188 | self._sigma = sigma 189 | self._D: Optional[np.ndarray] = None 190 | self._B: Optional[np.ndarray] = None 191 | 192 | # categorical distribution 193 | # Parameters in categorical distribution with fewer categories 194 | # must be zero-padded at the end. 195 | self._K = cat_num 196 | self._Kmax = np.max(self._K) 197 | if cat_param is None: 198 | self._q = np.zeros((self._n_ca, self._Kmax)) 199 | for i in range(self._n_ca): 200 | self._q[i, : self._K[i]] = 1 / self._K[i] 201 | else: 202 | assert cat_param.shape == ( 203 | self._n_ca, 204 | self._Kmax, 205 | ), "Invalid shape of categorical distribution parameter" 206 | for i in range(self._n_ca): 207 | assert np.all(cat_param[i, self._K[i] :] == 0), ( 208 | "Parameters in categorical distribution with fewer categories " 209 | "must be zero-padded at the end" 210 | ) 211 | assert np.all( 212 | (cat_param >= 0) & (cat_param <= 1) 213 | ), "All elements in categorical distribution parameter must be between 0 and 1" 214 | assert np.allclose( 215 | np.sum(cat_param, axis=1), 1 216 | ), "Each row in categorical distribution parameter must sum to 1" 217 | self._q = cat_param 218 | 219 | self._q_min = ( 220 | margin 221 | if margin is not None 222 | else (1 - 0.73 ** (1 / self._n_ca)) / (self._K - 1) 223 | ) 224 | self._min_eigenvalue = min_eigenvalue if min_eigenvalue is not None else 1e-30 225 | 226 | # ASNG 227 | self._param_sum = np.sum(cat_num - 1) 228 | self._alpha = 1.5 229 | self._delta_init = 1.0 230 | self._Delta = 1.0 231 | self._Delta_max = np.inf 232 | self._gamma = 0.0 233 | self._s = np.zeros(self._param_sum) 234 | self._delta = self._delta_init / self._Delta 235 | self._eps = self._delta 236 | 237 | # bounds contains low and high of each parameter. 238 | assert bounds is None or _is_valid_bounds(bounds, mean), "invalid bounds" 239 | self._bounds = bounds 240 | self._n_max_resampling = n_max_resampling 241 | 242 | self._g = 0 243 | self._rng = np.random.RandomState(seed) 244 | 245 | # Termination criteria 246 | self._tolxup = 1e4 247 | self._tolfun = 1e-12 248 | self._tolconditioncov = 1e14 249 | 250 | self._funhist_term = 10 + math.ceil(30 * self._n_co / population_size) 251 | self._funhist_values = np.empty(self._funhist_term) 252 | 253 | def __getstate__(self) -> dict[str, Any]: 254 | attrs = {} 255 | for name in self.__dict__: 256 | # Remove _rng in pickle serialized object. 257 | if name == "_rng": 258 | continue 259 | if name == "_C": 260 | sym1d = _compress_symmetric(self._C) 261 | attrs["_c_1d"] = sym1d 262 | continue 263 | attrs[name] = getattr(self, name) 264 | return attrs 265 | 266 | def __setstate__(self, state: dict[str, Any]) -> None: 267 | state["_C"] = _decompress_symmetric(state["_c_1d"]) 268 | del state["_c_1d"] 269 | self.__dict__.update(state) 270 | # Set _rng for unpickled object. 271 | setattr(self, "_rng", np.random.RandomState()) 272 | 273 | @property 274 | def cont_dim(self) -> int: 275 | """A number of dimensions of continuous variable""" 276 | return self._n_co 277 | 278 | @property 279 | def cat_dim(self) -> int: 280 | """A number of dimensions of categorical variable""" 281 | return self._n_ca 282 | 283 | @property 284 | def dim(self) -> int: 285 | """A number of dimensions""" 286 | return self._n 287 | 288 | @property 289 | def cat_num(self) -> np.ndarray: 290 | """Numbers of categories""" 291 | return self._K 292 | 293 | @property 294 | def population_size(self) -> int: 295 | """A population size""" 296 | return self._popsize 297 | 298 | @property 299 | def generation(self) -> int: 300 | """Generation number which is monotonically incremented 301 | when multi-variate gaussian distribution is updated.""" 302 | return self._g 303 | 304 | @property 305 | def mean(self) -> np.ndarray: 306 | """Mean Vector""" 307 | return self._mean 308 | 309 | def reseed_rng(self, seed: int) -> None: 310 | self._rng.seed(seed) 311 | 312 | def set_bounds(self, bounds: Optional[np.ndarray]) -> None: 313 | """Update boundary constraints""" 314 | assert bounds is None or _is_valid_bounds(bounds, self._mean), "invalid bounds" 315 | self._bounds = bounds 316 | 317 | def ask(self) -> tuple[np.ndarray, np.ndarray]: 318 | """Sample a parameter""" 319 | for i in range(self._n_max_resampling): 320 | x, c = self._sample_solution() 321 | if self._is_feasible(x): 322 | return x, c 323 | x, c = self._sample_solution() 324 | x = self._repair_infeasible_params(x) 325 | return x, c 326 | 327 | def _eigen_decomposition(self) -> tuple[np.ndarray, np.ndarray]: 328 | if self._B is not None and self._D is not None: 329 | return self._B, self._D 330 | 331 | self._C = (self._C + self._C.T) / 2 332 | D2, B = np.linalg.eigh(self._C) 333 | D = np.sqrt(np.where(D2 < 0, _EPS, D2)) 334 | self._C = np.dot(np.dot(B, np.diag(D**2)), B.T) 335 | 336 | self._B, self._D = B, D 337 | return B, D 338 | 339 | def _sample_solution(self) -> tuple[np.ndarray, np.ndarray]: 340 | # x : continuous variable 341 | B, D = self._eigen_decomposition() 342 | z = self._rng.randn(self._n_co) # ~ N(0, I) 343 | y = cast(np.ndarray, B.dot(np.diag(D))).dot(z) # ~ N(0, C) 344 | x = self._mean + self._sigma * y # ~ N(m, σ^2 C) 345 | # c : categorical variable 346 | # Categorical variables are one-hot encoded. 347 | # Variables with fewer categories are zero-padded at the end. 348 | rand_q = self._rng.rand(self._n_ca, 1) 349 | cum_q = self._q.cumsum(axis=1) 350 | c = (cum_q - self._q <= rand_q) & (rand_q < cum_q) 351 | return x, c 352 | 353 | def _is_feasible(self, param: np.ndarray) -> bool: 354 | if self._bounds is None: 355 | return True 356 | return cast( 357 | bool, 358 | np.all(param >= self._bounds[:, 0]) and np.all(param <= self._bounds[:, 1]), 359 | ) # Cast bool_ to bool. 360 | 361 | def _repair_infeasible_params(self, param: np.ndarray) -> np.ndarray: 362 | if self._bounds is None: 363 | return param 364 | 365 | # clip with lower and upper bound. 366 | param = np.where(param < self._bounds[:, 0], self._bounds[:, 0], param) 367 | param = np.where(param > self._bounds[:, 1], self._bounds[:, 1], param) 368 | return param 369 | 370 | def tell( 371 | self, solutions: list[tuple[tuple[np.ndarray, np.ndarray], float]] 372 | ) -> None: 373 | """Tell evaluation values""" 374 | 375 | assert len(solutions) == self._popsize, "Must tell popsize-length solutions." 376 | for s in solutions: 377 | assert np.all( 378 | np.abs(s[0][0]) < _MEAN_MAX 379 | ), f"Abs of all param values must be less than {_MEAN_MAX} to avoid overflow errors" 380 | 381 | self._g += 1 382 | solutions.sort(key=lambda s: s[1]) 383 | 384 | # Stores best evaluation values of the 385 | # last 'self._funhist_term' generations. 386 | funhist_idx = self.generation % self._funhist_term 387 | self._funhist_values[funhist_idx] = solutions[0][1] 388 | 389 | # Sample new population of search_points, for k=1, ..., popsize 390 | B, D = self._eigen_decomposition() 391 | self._B, self._D = None, None 392 | 393 | x_k = np.array([s[0][0] for s in solutions]) # ~ N(m, σ^2 C) 394 | y_k = (x_k - self._mean) / self._sigma # ~ N(0, C) 395 | 396 | # Selection and recombination 397 | y_w = np.sum(y_k[: self._mu].T * self._weights[: self._mu], axis=1) 398 | self._mean += self._cm * self._sigma * y_w 399 | 400 | # Step-size control 401 | C_2 = cast( 402 | np.ndarray, cast(np.ndarray, B.dot(np.diag(1 / D))).dot(B.T) 403 | ) # C^(-1/2) = B D^(-1) B^T 404 | self._p_sigma = (1 - self._c_sigma) * self._p_sigma + math.sqrt( 405 | self._c_sigma * (2 - self._c_sigma) * self._mu_eff 406 | ) * C_2.dot(y_w) 407 | 408 | norm_p_sigma = np.linalg.norm(self._p_sigma) 409 | self._sigma *= np.exp( 410 | (self._c_sigma / self._d_sigma) * (norm_p_sigma / self._chi_n - 1) 411 | ) 412 | self._sigma = min(self._sigma, _SIGMA_MAX) 413 | 414 | # Covariance matrix adaption 415 | h_sigma_cond_left = norm_p_sigma / math.sqrt( 416 | 1 - (1 - self._c_sigma) ** (2 * (self._g + 1)) 417 | ) 418 | h_sigma_cond_right = (1.4 + 2 / (self._n_co + 1)) * self._chi_n 419 | h_sigma = 1.0 if h_sigma_cond_left < h_sigma_cond_right else 0.0 420 | 421 | self._pc = (1 - self._cc) * self._pc + h_sigma * math.sqrt( 422 | self._cc * (2 - self._cc) * self._mu_eff 423 | ) * y_w 424 | 425 | delta_h_sigma = (1 - h_sigma) * self._cc * (2 - self._cc) 426 | assert delta_h_sigma <= 1 427 | 428 | rank_one = np.outer(self._pc, self._pc) 429 | rank_mu = np.sum( 430 | np.array([w * np.outer(y, y) for w, y in zip(self._weights, y_k)]), axis=0 431 | ) 432 | self._C = ( 433 | ( 434 | 1 435 | + self._c1 * delta_h_sigma 436 | - self._c1 437 | - self._cmu * np.sum(self._weights) 438 | ) 439 | * self._C 440 | + self._c1 * rank_one 441 | + self._cmu * rank_mu 442 | ) 443 | 444 | # Post-processing to prevent the minimum eigenvalue from becoming too small 445 | B, D = self._eigen_decomposition() 446 | sigma_min = np.sqrt(self._min_eigenvalue / np.min(D)) 447 | self._sigma = max(self._sigma, sigma_min) 448 | 449 | # Update of categorical distribution 450 | c = np.array([s[0][1] for s in solutions]) 451 | ngrad = (self._weights[:, np.newaxis, np.newaxis] * (c - self._q)).sum(axis=0) 452 | 453 | # Approximation of the square root of the fisher information matrix : 454 | # Appendix B in https://proceedings.mlr.press/v97/akimoto19a.html 455 | sl = [] 456 | for i, K in enumerate(self._K): 457 | q_i = self._q[i, : K - 1] 458 | q_i_K = self._q[i, K - 1] 459 | s_i = 1.0 / np.sqrt(q_i) * ngrad[i, : K - 1] 460 | s_i += np.sqrt(q_i) * ngrad[i, : K - 1].sum() / (q_i_K + np.sqrt(q_i_K)) 461 | sl += list(s_i) 462 | ngrad_sqF = np.array(sl) 463 | 464 | pnorm = np.sqrt(np.dot(ngrad_sqF, ngrad_sqF)) + 1e-30 465 | self._eps = self._delta / pnorm 466 | self._q += self._eps * ngrad 467 | 468 | # Update of ASNG 469 | self._delta = self._delta_init / self._Delta 470 | beta = self._delta / (self._param_sum**0.5) 471 | self._s = (1 - beta) * self._s + np.sqrt(beta * (2 - beta)) * ngrad_sqF / pnorm 472 | self._gamma = (1 - beta) ** 2 * self._gamma + beta * (2 - beta) 473 | self._Delta *= np.exp( 474 | beta * (self._gamma - np.dot(self._s, self._s) / self._alpha) 475 | ) 476 | self._Delta = min(self._Delta, self._Delta_max) 477 | 478 | # Margin Correction 479 | for i in range(self._n_ca): 480 | Ki = self._K[i] 481 | self._q[i, :Ki] = np.maximum(self._q[i, :Ki], self._q_min[i]) 482 | q_sum = self._q[i, :Ki].sum() 483 | tmp = q_sum - self._q_min[i] * Ki 484 | self._q[i, :Ki] -= (q_sum - 1) * (self._q[i, :Ki] - self._q_min[i]) / tmp 485 | self._q[i, :Ki] /= self._q[i, :Ki].sum() 486 | 487 | def should_stop(self) -> bool: 488 | B, D = self._eigen_decomposition() 489 | 490 | # Stop if the range of function values of the recent generation is below tolfun. 491 | if ( 492 | self.generation > self._funhist_term 493 | and np.max(self._funhist_values) - np.min(self._funhist_values) 494 | < self._tolfun 495 | ): 496 | return True 497 | 498 | # Stop if detecting divergent behavior. 499 | if self._sigma * np.max(D) > self._tolxup: 500 | return True 501 | 502 | # Stop if the condition number of the covariance matrix exceeds 1e14. 503 | condition_cov = np.max(D) / np.min(D) 504 | if condition_cov > self._tolconditioncov: 505 | return True 506 | 507 | return False 508 | 509 | 510 | def _is_valid_bounds(bounds: Optional[np.ndarray], mean: np.ndarray) -> bool: 511 | if bounds is None: 512 | return True 513 | if (mean.size, 2) != bounds.shape: 514 | return False 515 | if not np.all(bounds[:, 0] <= mean): 516 | return False 517 | if not np.all(mean <= bounds[:, 1]): 518 | return False 519 | return True 520 | 521 | 522 | def _compress_symmetric(sym2d: np.ndarray) -> np.ndarray: 523 | assert len(sym2d.shape) == 2 and sym2d.shape[0] == sym2d.shape[1] 524 | n = sym2d.shape[0] 525 | dim = (n * (n + 1)) // 2 526 | sym1d = np.zeros(dim) 527 | start = 0 528 | for i in range(n): 529 | sym1d[start : start + n - i] = sym2d[i][i:] # noqa: E203 530 | start += n - i 531 | return sym1d 532 | 533 | 534 | def _decompress_symmetric(sym1d: np.ndarray) -> np.ndarray: 535 | n = int(np.sqrt(sym1d.size * 2)) 536 | assert (n * (n + 1)) // 2 == sym1d.size 537 | R, C = np.triu_indices(n) 538 | out = np.zeros((n, n), dtype=sym1d.dtype) 539 | out[R, C] = sym1d 540 | out[C, R] = sym1d 541 | return out 542 | -------------------------------------------------------------------------------- /cmaes/_cma.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | import numpy as np 5 | 6 | from typing import Any 7 | from typing import cast 8 | from typing import Optional 9 | 10 | 11 | _EPS = 1e-8 12 | _MEAN_MAX = 1e32 13 | _SIGMA_MAX = 1e32 14 | 15 | 16 | class CMA: 17 | """CMA-ES stochastic optimizer class with ask-and-tell interface. 18 | 19 | Example: 20 | 21 | .. code:: 22 | 23 | import numpy as np 24 | from cmaes import CMA 25 | 26 | def quadratic(x1, x2): 27 | return (x1 - 3) ** 2 + (10 * (x2 + 2)) ** 2 28 | 29 | optimizer = CMA(mean=np.zeros(2), sigma=1.3) 30 | 31 | for generation in range(50): 32 | solutions = [] 33 | for _ in range(optimizer.population_size): 34 | # Ask a parameter 35 | x = optimizer.ask() 36 | value = quadratic(x[0], x[1]) 37 | solutions.append((x, value)) 38 | print(f"#{generation} {value} (x1={x[0]}, x2 = {x[1]})") 39 | 40 | # Tell evaluation values. 41 | optimizer.tell(solutions) 42 | 43 | Args: 44 | 45 | mean: 46 | Initial mean vector of multi-variate gaussian distributions. 47 | 48 | sigma: 49 | Initial standard deviation of covariance matrix. 50 | 51 | bounds: 52 | Lower and upper domain boundaries for each parameter (optional). 53 | 54 | n_max_resampling: 55 | A maximum number of resampling parameters (default: 100). 56 | If all sampled parameters are infeasible, the last sampled one 57 | will be clipped with lower and upper bounds. 58 | 59 | seed: 60 | A seed number (optional). 61 | 62 | population_size: 63 | A population size (optional). 64 | 65 | cov: 66 | A covariance matrix (optional). 67 | 68 | lr_adapt: 69 | Flag for learning rate adaptation (optional; default=False) 70 | """ 71 | 72 | def __init__( 73 | self, 74 | mean: np.ndarray, 75 | sigma: float, 76 | bounds: Optional[np.ndarray] = None, 77 | n_max_resampling: int = 100, 78 | seed: Optional[int] = None, 79 | population_size: Optional[int] = None, 80 | cov: Optional[np.ndarray] = None, 81 | lr_adapt: bool = False, 82 | ): 83 | assert sigma > 0, "sigma must be non-zero positive value" 84 | 85 | assert np.all( 86 | np.abs(mean) < _MEAN_MAX 87 | ), f"Abs of all elements of mean vector must be less than {_MEAN_MAX}" 88 | 89 | n_dim = len(mean) 90 | assert n_dim > 0, "The dimension of mean must be positive" 91 | 92 | if population_size is None: 93 | population_size = 4 + math.floor(3 * math.log(n_dim)) # (eq. 48) 94 | assert population_size > 0, "popsize must be non-zero positive value." 95 | 96 | mu = population_size // 2 97 | 98 | # (eq.49) 99 | weights_prime = np.array( 100 | [ 101 | math.log((population_size + 1) / 2) - math.log(i + 1) 102 | for i in range(population_size) 103 | ] 104 | ) 105 | mu_eff = (np.sum(weights_prime[:mu]) ** 2) / np.sum(weights_prime[:mu] ** 2) 106 | mu_eff_minus = (np.sum(weights_prime[mu:]) ** 2) / np.sum( 107 | weights_prime[mu:] ** 2 108 | ) 109 | 110 | # learning rate for the rank-one update 111 | alpha_cov = 2 112 | c1 = alpha_cov / ((n_dim + 1.3) ** 2 + mu_eff) 113 | # learning rate for the rank-μ update 114 | cmu = min( 115 | 1 - c1 - 1e-8, # 1e-8 is for large popsize. 116 | alpha_cov 117 | * (mu_eff - 2 + 1 / mu_eff) 118 | / ((n_dim + 2) ** 2 + alpha_cov * mu_eff / 2), 119 | ) 120 | assert c1 <= 1 - cmu, "invalid learning rate for the rank-one update" 121 | assert cmu <= 1 - c1, "invalid learning rate for the rank-μ update" 122 | 123 | min_alpha = min( 124 | 1 + c1 / cmu, # eq.50 125 | 1 + (2 * mu_eff_minus) / (mu_eff + 2), # eq.51 126 | (1 - c1 - cmu) / (n_dim * cmu), # eq.52 127 | ) 128 | 129 | # (eq.53) 130 | positive_sum = np.sum(weights_prime[weights_prime > 0]) 131 | negative_sum = np.sum(np.abs(weights_prime[weights_prime < 0])) 132 | weights = np.where( 133 | weights_prime >= 0, 134 | 1 / positive_sum * weights_prime, 135 | min_alpha / negative_sum * weights_prime, 136 | ) 137 | cm = 1 # (eq. 54) 138 | 139 | # learning rate for the cumulation for the step-size control (eq.55) 140 | c_sigma = (mu_eff + 2) / (n_dim + mu_eff + 5) 141 | d_sigma = 1 + 2 * max(0, math.sqrt((mu_eff - 1) / (n_dim + 1)) - 1) + c_sigma 142 | assert ( 143 | c_sigma < 1 144 | ), "invalid learning rate for cumulation for the step-size control" 145 | 146 | # learning rate for cumulation for the rank-one update (eq.56) 147 | cc = (4 + mu_eff / n_dim) / (n_dim + 4 + 2 * mu_eff / n_dim) 148 | assert cc <= 1, "invalid learning rate for cumulation for the rank-one update" 149 | 150 | self._n_dim = n_dim 151 | self._popsize = population_size 152 | self._mu = mu 153 | self._mu_eff = mu_eff 154 | 155 | self._cc = cc 156 | self._c1 = c1 157 | self._cmu = cmu 158 | self._c_sigma = c_sigma 159 | self._d_sigma = d_sigma 160 | self._cm = cm 161 | 162 | # E||N(0, I)|| (p.28) 163 | self._chi_n = math.sqrt(self._n_dim) * ( 164 | 1.0 - (1.0 / (4.0 * self._n_dim)) + 1.0 / (21.0 * (self._n_dim**2)) 165 | ) 166 | 167 | self._weights = weights 168 | 169 | # evolution path 170 | self._p_sigma = np.zeros(n_dim) 171 | self._pc = np.zeros(n_dim) 172 | 173 | self._mean = mean.copy() 174 | 175 | if cov is None: 176 | self._C = np.eye(n_dim) 177 | else: 178 | assert cov.shape == (n_dim, n_dim), "Invalid shape of covariance matrix" 179 | self._C = cov 180 | 181 | self._sigma = sigma 182 | self._D: Optional[np.ndarray] = None 183 | self._B: Optional[np.ndarray] = None 184 | 185 | # bounds contains low and high of each parameter. 186 | assert bounds is None or _is_valid_bounds(bounds, mean), "invalid bounds" 187 | self._bounds = bounds 188 | self._n_max_resampling = n_max_resampling 189 | 190 | self._g = 0 191 | self._rng = np.random.RandomState(seed) 192 | 193 | # for learning rate adaptation 194 | self._lr_adapt = lr_adapt 195 | self._alpha = 1.4 196 | self._beta_mean = 0.1 197 | self._beta_Sigma = 0.03 198 | self._gamma = 0.1 199 | self._Emean = np.zeros([self._n_dim, 1]) 200 | self._ESigma = np.zeros([self._n_dim * self._n_dim, 1]) 201 | self._Vmean = 0.0 202 | self._VSigma = 0.0 203 | self._eta_mean = 1.0 204 | self._eta_Sigma = 1.0 205 | 206 | # Termination criteria 207 | self._tolx = 1e-12 * sigma 208 | self._tolxup = 1e4 209 | self._tolfun = 1e-12 210 | self._tolconditioncov = 1e14 211 | 212 | self._funhist_term = 10 + math.ceil(30 * n_dim / population_size) 213 | self._funhist_values = np.empty(self._funhist_term * 2) 214 | 215 | def __getstate__(self) -> dict[str, Any]: 216 | attrs = {} 217 | for name in self.__dict__: 218 | # Remove _rng in pickle serialized object. 219 | if name == "_rng": 220 | continue 221 | if name == "_C": 222 | sym1d = _compress_symmetric(self._C) 223 | attrs["_c_1d"] = sym1d 224 | continue 225 | attrs[name] = getattr(self, name) 226 | return attrs 227 | 228 | def __setstate__(self, state: dict[str, Any]) -> None: 229 | state["_C"] = _decompress_symmetric(state["_c_1d"]) 230 | del state["_c_1d"] 231 | self.__dict__.update(state) 232 | # Set _rng for unpickled object. 233 | setattr(self, "_rng", np.random.RandomState()) 234 | 235 | @property 236 | def dim(self) -> int: 237 | """A number of dimensions""" 238 | return self._n_dim 239 | 240 | @property 241 | def population_size(self) -> int: 242 | """A population size""" 243 | return self._popsize 244 | 245 | @property 246 | def generation(self) -> int: 247 | """Generation number which is monotonically incremented 248 | when multi-variate gaussian distribution is updated.""" 249 | return self._g 250 | 251 | @property 252 | def mean(self) -> np.ndarray: 253 | """Mean Vector""" 254 | return self._mean 255 | 256 | def reseed_rng(self, seed: int) -> None: 257 | self._rng.seed(seed) 258 | 259 | def set_bounds(self, bounds: Optional[np.ndarray]) -> None: 260 | """Update boundary constraints""" 261 | assert bounds is None or _is_valid_bounds(bounds, self._mean), "invalid bounds" 262 | self._bounds = bounds 263 | 264 | def ask(self) -> np.ndarray: 265 | """Sample a parameter""" 266 | for i in range(self._n_max_resampling): 267 | x = self._sample_solution() 268 | if self._is_feasible(x): 269 | return x 270 | x = self._sample_solution() 271 | x = self._repair_infeasible_params(x) 272 | return x 273 | 274 | def _eigen_decomposition(self) -> tuple[np.ndarray, np.ndarray]: 275 | if self._B is not None and self._D is not None: 276 | return self._B, self._D 277 | 278 | self._C = (self._C + self._C.T) / 2 279 | D2, B = np.linalg.eigh(self._C) 280 | D = np.sqrt(np.where(D2 < 0, _EPS, D2)) 281 | self._C = np.dot(np.dot(B, np.diag(D**2)), B.T) 282 | 283 | self._B, self._D = B, D 284 | return B, D 285 | 286 | def _sample_solution(self) -> np.ndarray: 287 | B, D = self._eigen_decomposition() 288 | z = self._rng.randn(self._n_dim) # ~ N(0, I) 289 | y = cast(np.ndarray, B.dot(np.diag(D))).dot(z) # ~ N(0, C) 290 | x = self._mean + self._sigma * y # ~ N(m, σ^2 C) 291 | return x 292 | 293 | def _is_feasible(self, param: np.ndarray) -> bool: 294 | if self._bounds is None: 295 | return True 296 | return cast( 297 | bool, 298 | np.all(param >= self._bounds[:, 0]) and np.all(param <= self._bounds[:, 1]), 299 | ) # Cast bool_ to bool. 300 | 301 | def _repair_infeasible_params(self, param: np.ndarray) -> np.ndarray: 302 | if self._bounds is None: 303 | return param 304 | 305 | # clip with lower and upper bound. 306 | param = np.where(param < self._bounds[:, 0], self._bounds[:, 0], param) 307 | param = np.where(param > self._bounds[:, 1], self._bounds[:, 1], param) 308 | return param 309 | 310 | def tell(self, solutions: list[tuple[np.ndarray, float]]) -> None: 311 | """Tell evaluation values""" 312 | 313 | assert len(solutions) == self._popsize, "Must tell popsize-length solutions." 314 | for s in solutions: 315 | assert np.all( 316 | np.abs(s[0]) < _MEAN_MAX 317 | ), f"Abs of all param values must be less than {_MEAN_MAX} to avoid overflow errors" 318 | 319 | self._g += 1 320 | solutions.sort(key=lambda s: s[1]) 321 | 322 | # Stores 'best' and 'worst' values of the 323 | # last 'self._funhist_term' generations. 324 | funhist_idx = 2 * (self.generation % self._funhist_term) 325 | self._funhist_values[funhist_idx] = solutions[0][1] 326 | self._funhist_values[funhist_idx + 1] = solutions[-1][1] 327 | 328 | # Sample new population of search_points, for k=1, ..., popsize 329 | B, D = self._eigen_decomposition() 330 | self._B, self._D = None, None 331 | 332 | # keep old values for learning rate adaptation 333 | if self._lr_adapt: 334 | old_mean = np.copy(self._mean) 335 | old_sigma = self._sigma 336 | old_Sigma = self._sigma**2 * self._C 337 | old_invsqrtC = B @ np.diag(1 / D) @ B.T 338 | else: 339 | old_mean, old_sigma, old_Sigma, old_invsqrtC = None, None, None, None 340 | 341 | x_k = np.array([s[0] for s in solutions]) # ~ N(m, σ^2 C) 342 | y_k = (x_k - self._mean) / self._sigma # ~ N(0, C) 343 | 344 | # Selection and recombination 345 | y_w = np.sum(y_k[: self._mu].T * self._weights[: self._mu], axis=1) # eq.41 346 | self._mean += self._cm * self._sigma * y_w 347 | 348 | # Step-size control 349 | C_2 = cast( 350 | np.ndarray, cast(np.ndarray, B.dot(np.diag(1 / D))).dot(B.T) 351 | ) # C^(-1/2) = B D^(-1) B^T 352 | self._p_sigma = (1 - self._c_sigma) * self._p_sigma + math.sqrt( 353 | self._c_sigma * (2 - self._c_sigma) * self._mu_eff 354 | ) * C_2.dot(y_w) 355 | 356 | norm_p_sigma = np.linalg.norm(self._p_sigma) 357 | self._sigma *= np.exp( 358 | (self._c_sigma / self._d_sigma) * (norm_p_sigma / self._chi_n - 1) 359 | ) 360 | self._sigma = min(self._sigma, _SIGMA_MAX) 361 | 362 | # Covariance matrix adaption 363 | h_sigma_cond_left = norm_p_sigma / math.sqrt( 364 | 1 - (1 - self._c_sigma) ** (2 * (self._g + 1)) 365 | ) 366 | h_sigma_cond_right = (1.4 + 2 / (self._n_dim + 1)) * self._chi_n 367 | h_sigma = 1.0 if h_sigma_cond_left < h_sigma_cond_right else 0.0 # (p.28) 368 | 369 | # (eq.45) 370 | self._pc = (1 - self._cc) * self._pc + h_sigma * math.sqrt( 371 | self._cc * (2 - self._cc) * self._mu_eff 372 | ) * y_w 373 | 374 | # (eq.46) 375 | w_io = self._weights * np.where( 376 | self._weights >= 0, 377 | 1, 378 | self._n_dim / (np.linalg.norm(C_2.dot(y_k.T), axis=0) ** 2 + _EPS), 379 | ) 380 | 381 | delta_h_sigma = (1 - h_sigma) * self._cc * (2 - self._cc) # (p.28) 382 | assert delta_h_sigma <= 1 383 | 384 | # (eq.47) 385 | rank_one = np.outer(self._pc, self._pc) 386 | 387 | rank_mu = np.sum( 388 | w_io.reshape(-1, 1, 1) * np.einsum("...i,...j->...ij", y_k, y_k), axis=0 389 | ) 390 | # The above line is equivalent to: 391 | # rank_mu = np.sum(np.array([w * np.outer(y, y) for w, y in zip(w_io, y_k)]), axis=0) 392 | 393 | self._C = ( 394 | ( 395 | 1 396 | + self._c1 * delta_h_sigma 397 | - self._c1 398 | - self._cmu * np.sum(self._weights) 399 | ) 400 | * self._C 401 | + self._c1 * rank_one 402 | + self._cmu * rank_mu 403 | ) 404 | 405 | # Learning rate adaptation: https://arxiv.org/abs/2304.03473 406 | if self._lr_adapt: 407 | assert isinstance(old_mean, np.ndarray) 408 | assert isinstance(old_sigma, (int, float)) 409 | assert isinstance(old_Sigma, np.ndarray) 410 | assert isinstance(old_invsqrtC, np.ndarray) 411 | self._lr_adaptation(old_mean, old_sigma, old_Sigma, old_invsqrtC) 412 | 413 | def _lr_adaptation( 414 | self, 415 | old_mean: np.ndarray, 416 | old_sigma: float, 417 | old_Sigma: np.ndarray, 418 | old_invsqrtC: np.ndarray, 419 | ) -> None: 420 | # calculate one-step difference of the parameters 421 | Deltamean = (self._mean - old_mean).reshape([self._n_dim, 1]) 422 | Sigma = (self._sigma**2) * self._C 423 | # note that we use here matrix representation instead of vec one 424 | DeltaSigma = Sigma - old_Sigma 425 | 426 | # local coordinate 427 | old_inv_sqrtSigma = old_invsqrtC / old_sigma 428 | locDeltamean = old_inv_sqrtSigma.dot(Deltamean) 429 | locDeltaSigma = ( 430 | old_inv_sqrtSigma.dot(DeltaSigma.dot(old_inv_sqrtSigma)) 431 | ).reshape(self.dim * self.dim, 1) / np.sqrt(2) 432 | 433 | # moving average E and V 434 | self._Emean = ( 435 | 1 - self._beta_mean 436 | ) * self._Emean + self._beta_mean * locDeltamean 437 | self._ESigma = ( 438 | 1 - self._beta_Sigma 439 | ) * self._ESigma + self._beta_Sigma * locDeltaSigma 440 | self._Vmean = (1 - self._beta_mean) * self._Vmean + self._beta_mean * ( 441 | float(np.linalg.norm(locDeltamean)) ** 2 442 | ) 443 | self._VSigma = (1 - self._beta_Sigma) * self._VSigma + self._beta_Sigma * ( 444 | float(np.linalg.norm(locDeltaSigma)) ** 2 445 | ) 446 | 447 | # estimate SNR 448 | sqnormEmean = np.linalg.norm(self._Emean) ** 2 449 | hatSNRmean = ( 450 | sqnormEmean - (self._beta_mean / (2 - self._beta_mean)) * self._Vmean 451 | ) / (self._Vmean - sqnormEmean) 452 | sqnormESigma = np.linalg.norm(self._ESigma) ** 2 453 | hatSNRSigma = ( 454 | sqnormESigma - (self._beta_Sigma / (2 - self._beta_Sigma)) * self._VSigma 455 | ) / (self._VSigma - sqnormESigma) 456 | 457 | # update learning rate 458 | before_eta_mean = self._eta_mean 459 | relativeSNRmean = np.clip( 460 | (hatSNRmean / self._alpha / self._eta_mean) - 1, -1, 1 461 | ) 462 | self._eta_mean = self._eta_mean * np.exp( 463 | min(self._gamma * self._eta_mean, self._beta_mean) * relativeSNRmean 464 | ) 465 | relativeSNRSigma = np.clip( 466 | (hatSNRSigma / self._alpha / self._eta_Sigma) - 1, -1, 1 467 | ) 468 | self._eta_Sigma = self._eta_Sigma * np.exp( 469 | min(self._gamma * self._eta_Sigma, self._beta_Sigma) * relativeSNRSigma 470 | ) 471 | # cap 472 | self._eta_mean = min(self._eta_mean, 1.0) 473 | self._eta_Sigma = min(self._eta_Sigma, 1.0) 474 | 475 | # update parameters 476 | self._mean = old_mean + self._eta_mean * Deltamean.reshape(self._n_dim) 477 | Sigma = old_Sigma + self._eta_Sigma * DeltaSigma 478 | 479 | # decompose Sigma to sigma and C 480 | eigs, _ = np.linalg.eigh(Sigma) 481 | logeigsum = sum([np.log(e) for e in eigs]) 482 | self._sigma = np.exp(logeigsum / 2.0 / self._n_dim) 483 | self._sigma = min(self._sigma, _SIGMA_MAX) 484 | self._C = (Sigma / (self._sigma**2)).astype(np.float64) 485 | 486 | # step-size correction 487 | self._sigma *= before_eta_mean / self._eta_mean 488 | 489 | def should_stop(self) -> bool: 490 | B, D = self._eigen_decomposition() 491 | dC = np.diag(self._C) 492 | 493 | # Stop if the range of function values of the recent generation is below tolfun. 494 | if ( 495 | self.generation > self._funhist_term 496 | and np.max(self._funhist_values) - np.min(self._funhist_values) 497 | < self._tolfun 498 | ): 499 | return True 500 | 501 | # Stop if the std of the normal distribution is smaller than tolx 502 | # in all coordinates and pc is smaller than tolx in all components. 503 | if np.all(self._sigma * dC < self._tolx) and np.all( 504 | self._sigma * self._pc < self._tolx 505 | ): 506 | return True 507 | 508 | # Stop if detecting divergent behavior. 509 | if self._sigma * np.max(D) > self._tolxup: 510 | return True 511 | 512 | # No effect coordinates: stop if adding 0.2-standard deviations 513 | # in any single coordinate does not change m. 514 | if np.any(self._mean == self._mean + (0.2 * self._sigma * np.sqrt(dC))): 515 | return True 516 | 517 | # No effect axis: stop if adding 0.1-standard deviation vector in 518 | # any principal axis direction of C does not change m. "pycma" check 519 | # axis one by one at each generation. 520 | i = self.generation % self.dim 521 | if np.all(self._mean == self._mean + (0.1 * self._sigma * D[i] * B[:, i])): 522 | return True 523 | 524 | # Stop if the condition number of the covariance matrix exceeds 1e14. 525 | condition_cov = np.max(D) / np.min(D) 526 | if condition_cov > self._tolconditioncov: 527 | return True 528 | 529 | return False 530 | 531 | 532 | def _is_valid_bounds(bounds: Optional[np.ndarray], mean: np.ndarray) -> bool: 533 | if bounds is None: 534 | return True 535 | if (mean.size, 2) != bounds.shape: 536 | return False 537 | if not np.all(bounds[:, 0] <= mean): 538 | return False 539 | if not np.all(mean <= bounds[:, 1]): 540 | return False 541 | return True 542 | 543 | 544 | def _compress_symmetric(sym2d: np.ndarray) -> np.ndarray: 545 | assert len(sym2d.shape) == 2 and sym2d.shape[0] == sym2d.shape[1] 546 | n = sym2d.shape[0] 547 | dim = (n * (n + 1)) // 2 548 | sym1d = np.zeros(dim) 549 | start = 0 550 | for i in range(n): 551 | sym1d[start : start + n - i] = sym2d[i][i:] # noqa: E203 552 | start += n - i 553 | return sym1d 554 | 555 | 556 | def _decompress_symmetric(sym1d: np.ndarray) -> np.ndarray: 557 | n = int(np.sqrt(sym1d.size * 2)) 558 | assert (n * (n + 1)) // 2 == sym1d.size 559 | R, C = np.triu_indices(n) 560 | out = np.zeros((n, n), dtype=sym1d.dtype) 561 | out[R, C] = sym1d 562 | out[C, R] = sym1d 563 | return out 564 | --------------------------------------------------------------------------------