diff --git a/.tools/envs/testenv-linux.yml b/.tools/envs/testenv-linux.yml index db2d074fb..eebe90d0d 100644 --- a/.tools/envs/testenv-linux.yml +++ b/.tools/envs/testenv-linux.yml @@ -39,6 +39,7 @@ dependencies: - kaleido>=1.0 # dev, tests - bayes_optim # dev, tests - gradient_free_optimizers # dev, tests + - pyswarms # dev, tests - pandas-stubs # dev, tests - types-cffi # dev, tests - types-openpyxl # dev, tests diff --git a/.tools/envs/testenv-nevergrad.yml b/.tools/envs/testenv-nevergrad.yml index 37c74ebd3..15bd1322f 100644 --- a/.tools/envs/testenv-nevergrad.yml +++ b/.tools/envs/testenv-nevergrad.yml @@ -36,12 +36,13 @@ dependencies: - kaleido>=1.0 # dev, tests - bayes_optim # dev, tests - gradient_free_optimizers # dev, tests + - pyswarms # dev, tests - pandas-stubs # dev, tests - types-cffi # dev, tests - types-openpyxl # dev, tests - types-jinja2 # dev, tests - sqlalchemy-stubs # dev, tests - - sphinxcontrib-mermaid # dev, tests, docs - bayesian_optimization==1.4.0 - nevergrad + - sphinxcontrib-mermaid # dev, tests, docs - -e ../../ diff --git a/.tools/envs/testenv-numpy.yml b/.tools/envs/testenv-numpy.yml index d5d26c22b..b9d35ef74 100644 --- a/.tools/envs/testenv-numpy.yml +++ b/.tools/envs/testenv-numpy.yml @@ -37,6 +37,7 @@ dependencies: - kaleido>=1.0 # dev, tests - bayes_optim # dev, tests - gradient_free_optimizers # dev, tests + - pyswarms # dev, tests - types-cffi # dev, tests - types-openpyxl # dev, tests - types-jinja2 # dev, tests diff --git a/.tools/envs/testenv-others.yml b/.tools/envs/testenv-others.yml index 15599c2a3..7d7f416c4 100644 --- a/.tools/envs/testenv-others.yml +++ b/.tools/envs/testenv-others.yml @@ -37,6 +37,7 @@ dependencies: - kaleido>=1.0 # dev, tests - bayes_optim # dev, tests - gradient_free_optimizers # dev, tests + - pyswarms # dev, tests - pandas-stubs # dev, tests - types-cffi # dev, tests - types-openpyxl # dev, tests diff --git a/.tools/envs/testenv-pandas.yml b/.tools/envs/testenv-pandas.yml index 3aff5dc1d..be1beff35 100644 --- a/.tools/envs/testenv-pandas.yml +++ b/.tools/envs/testenv-pandas.yml @@ -37,6 +37,7 @@ dependencies: - kaleido>=1.0 # dev, tests - bayes_optim # dev, tests - gradient_free_optimizers # dev, tests + - pyswarms # dev, tests - types-cffi # dev, tests - types-openpyxl # dev, tests - types-jinja2 # dev, tests diff --git a/.tools/envs/testenv-plotly.yml b/.tools/envs/testenv-plotly.yml index dff80c07e..29720829a 100644 --- a/.tools/envs/testenv-plotly.yml +++ b/.tools/envs/testenv-plotly.yml @@ -36,11 +36,12 @@ dependencies: - fides==0.7.4 # dev, tests - bayes_optim # dev, tests - gradient_free_optimizers # dev, tests + - pyswarms # dev, tests - pandas-stubs # dev, tests - types-cffi # dev, tests - types-openpyxl # dev, tests - types-jinja2 # dev, tests - sqlalchemy-stubs # dev, tests - - sphinxcontrib-mermaid # dev, tests, docs - kaleido<0.3 + - sphinxcontrib-mermaid # dev, tests, docs - -e ../../ diff --git a/docs/source/algorithms.md b/docs/source/algorithms.md index bdc9c27e5..1a3409f8f 100644 --- a/docs/source/algorithms.md +++ b/docs/source/algorithms.md @@ -4885,6 +4885,107 @@ We wrap the pygad optimizer. To use it you need to have .. autoclass:: optimagic.optimizers.pygad_optimizer.Pygad ``` +## PySwarms Optimizers + +optimagic supports the following continuous algorithms from the +[PySwarms](https://pyswarms.readthedocs.io/en/latest/) library: (GlobalBestPSO, +LocalBestPSO, GeneralOptimizerPSO). To use these optimizers, you need to have +[the pyswarms package](https://github.com/ljvmiranda921/pyswarms) installed. +(`pip install pyswarms`). + +```{eval-rst} +.. dropdown:: pyswarms_global_best + + **How to use this algorithm:** + + .. code-block:: + + import optimagic as om + om.minimize( + ..., + algorithm=om.algos.pyswarms_global_best(n_particles=50, ...) + ) + + or + + .. code-block:: + + om.minimize( + ..., + algorithm="pyswarms_global_best", + algo_options={"n_particles": 50, ...} + ) + + **Description and available options:** + + .. autoclass:: optimagic.optimizers.pyswarms_optimizers.PySwarmsGlobalBestPSO + :members: + :inherited-members: Algorithm, object + +``` + +```{eval-rst} +.. dropdown:: pyswarms_local_best + + **How to use this algorithm:** + + .. code-block:: + + import optimagic as om + om.minimize( + ..., + algorithm=om.algos.pyswarms_local_best(n_particles=50, k_neighbors=3, ...) + ) + + or + + .. code-block:: + + om.minimize( + ..., + algorithm="pyswarms_local_best", + algo_options={"n_particles": 50, "k_neighbors": 3, ...} + ) + + **Description and available options:** + + .. autoclass:: optimagic.optimizers.pyswarms_optimizers.PySwarmsLocalBestPSO + :members: + :inherited-members: Algorithm, object + +``` + +```{eval-rst} +.. dropdown:: pyswarms_general + + **How to use this algorithm:** + + .. code-block:: + + import optimagic as om + om.minimize( + ..., + algorithm=om.algos.pyswarms_general(n_particles=50, topology_type="star", ...) + ) + + or + + .. code-block:: + + om.minimize( + ..., + algorithm="pyswarms_general", + algo_options={"n_particles": 50, "topology_type": "star", ...} + ) + + **Description and available options:** + + .. autoclass:: optimagic.optimizers.pyswarms_optimizers.PySwarmsGeneralPSO + :members: + :inherited-members: Algorithm, object + +``` + ## References ```{eval-rst} diff --git a/docs/source/refs.bib b/docs/source/refs.bib index 485894194..7bc72a906 100644 --- a/docs/source/refs.bib +++ b/docs/source/refs.bib @@ -1077,4 +1077,40 @@ @article{gad2023pygad publisher={Springer} } +@INPROCEEDINGS{EberhartKennedy1995, + author = {Eberhart, R. and Kennedy, J.}, + booktitle = {MHS'95. Proceedings of the Sixth International Symposium on Micro Machine and Human Science}, + title = {A new optimizer using particle swarm theory}, + year = {1995}, + pages = {39-43}, + keywords = {Particle swarm optimization;Genetic algorithms;Testing;Acceleration;Particle tracking;Optimization methods;Artificial neural networks;Evolutionary computation;Performance evaluation;Statistics}, + doi = {10.1109/MHS.1995.494215} +} + +@INPROCEEDINGS{Lane2008SpatialPSO, + author={Lane, James and Engelbrecht, Andries and Gain, James}, + booktitle={2008 IEEE Swarm Intelligence Symposium}, + title={Particle swarm optimization with spatially meaningful neighbours}, + year={2008}, + volume={}, + number={}, + pages={1-8}, + keywords={Particle swarm optimization;Topology;Birds;Convergence;Computer science;USA Councils;Cities and towns;Africa;Cultural differences;Data structures;Delaunay Triangulation;Neighbour Topology;Particle Swarm Optimization;Heuristics}, + doi={10.1109/SIS.2008.4668281} +} + +@article{Ni2013, +author = {Ni, Qingjian and Deng, Jianming}, +title = {A New Logistic Dynamic Particle Swarm Optimization Algorithm Based on Random Topology}, +journal = {The Scientific World Journal}, +volume = {2013}, +number = {1}, +pages = {409167}, +doi = {https://doi.org/10.1155/2013/409167}, +url = {https://onlinelibrary.wiley.com/doi/abs/10.1155/2013/409167}, +eprint = {https://onlinelibrary.wiley.com/doi/pdf/10.1155/2013/409167}, +abstract = {Population topology of particle swarm optimization (PSO) will directly affect the dissemination of optimal information during the evolutionary process and will have a significant impact on the performance of PSO. Classic static population topologies are usually used in PSO, such as fully connected topology, ring topology, star topology, and square topology. In this paper, the performance of PSO with the proposed random topologies is analyzed, and the relationship between population topology and the performance of PSO is also explored from the perspective of graph theory characteristics in population topologies. Further, in a relatively new PSO variant which named logistic dynamic particle optimization, an extensive simulation study is presented to discuss the effectiveness of the random topology and the design strategies of population topology. Finally, the experimental data are analyzed and discussed. And about the design and use of population topology on PSO, some useful conclusions are proposed which can provide a basis for further discussion and research.}, +year = {2013} +} + @Comment{jabref-meta: databaseType:bibtex;} diff --git a/environment.yml b/environment.yml index 27a69d1e5..d4b4f9c74 100644 --- a/environment.yml +++ b/environment.yml @@ -51,6 +51,7 @@ dependencies: - pre-commit>=4 # dev - bayes_optim # dev, tests - gradient_free_optimizers # dev, tests + - pyswarms # dev, tests - -e . # dev # type stubs - pandas-stubs # dev, tests diff --git a/pyproject.toml b/pyproject.toml index ac9ad7ded..0c42c7972 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -384,6 +384,8 @@ module = [ "iminuit", "nevergrad", "pygad", + "pyswarms", + "pyswarms.backend.topology", "yaml", "gradient_free_optimizers", "gradient_free_optimizers.optimizers.base_optimizer", diff --git a/src/optimagic/algorithms.py b/src/optimagic/algorithms.py index c7fa34cb2..45b8cd2f2 100644 --- a/src/optimagic/algorithms.py +++ b/src/optimagic/algorithms.py @@ -80,6 +80,11 @@ PygmoSimulatedAnnealing, PygmoXnes, ) +from optimagic.optimizers.pyswarms_optimizers import ( + PySwarmsGeneralPSO, + PySwarmsGlobalBestPSO, + PySwarmsLocalBestPSO, +) from optimagic.optimizers.scipy_optimizers import ( ScipyBasinhopping, ScipyBFGS, @@ -212,6 +217,9 @@ class BoundedGlobalGradientFreeParallelScalarAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -446,6 +454,9 @@ class BoundedGlobalGradientFreeScalarAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -481,6 +492,9 @@ class BoundedGlobalGradientFreeParallelAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -550,6 +564,9 @@ class GlobalGradientFreeParallelScalarAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -711,6 +728,9 @@ class BoundedGradientFreeParallelScalarAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -819,6 +839,9 @@ class BoundedGlobalParallelScalarAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -1186,6 +1209,9 @@ class BoundedGlobalGradientFreeAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -1272,6 +1298,9 @@ class GlobalGradientFreeScalarAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -1311,6 +1340,9 @@ class GlobalGradientFreeParallelAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -1522,6 +1554,9 @@ class BoundedGradientFreeScalarAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -1585,6 +1620,9 @@ class BoundedGradientFreeParallelAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -1679,6 +1717,9 @@ class GradientFreeParallelScalarAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -1788,6 +1829,9 @@ class BoundedGlobalScalarAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_basinhopping: Type[ScipyBasinhopping] = ScipyBasinhopping scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( @@ -1832,6 +1876,9 @@ class BoundedGlobalParallelAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -1914,6 +1961,9 @@ class GlobalParallelScalarAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -2162,6 +2212,9 @@ class BoundedParallelScalarAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -2461,6 +2514,9 @@ class GlobalGradientFreeAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -2577,6 +2633,9 @@ class BoundedGradientFreeAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -2696,6 +2755,9 @@ class GradientFreeScalarAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_cobyla: Type[ScipyCOBYLA] = ScipyCOBYLA scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( @@ -2767,6 +2829,9 @@ class GradientFreeParallelAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -2846,6 +2911,9 @@ class BoundedGlobalAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_basinhopping: Type[ScipyBasinhopping] = ScipyBasinhopping scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( @@ -2950,6 +3018,9 @@ class GlobalScalarAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_basinhopping: Type[ScipyBasinhopping] = ScipyBasinhopping scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( @@ -2998,6 +3069,9 @@ class GlobalParallelAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -3314,6 +3388,9 @@ class BoundedScalarAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_basinhopping: Type[ScipyBasinhopping] = ScipyBasinhopping scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( @@ -3400,6 +3477,9 @@ class BoundedParallelAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -3514,6 +3594,9 @@ class ParallelScalarAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -3669,6 +3752,9 @@ class GradientFreeAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_cobyla: Type[ScipyCOBYLA] = ScipyCOBYLA scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( @@ -3755,6 +3841,9 @@ class GlobalAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_basinhopping: Type[ScipyBasinhopping] = ScipyBasinhopping scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( @@ -3923,6 +4012,9 @@ class BoundedAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_basinhopping: Type[ScipyBasinhopping] = ScipyBasinhopping scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( @@ -4082,6 +4174,9 @@ class ScalarAlgorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_bfgs: Type[ScipyBFGS] = ScipyBFGS scipy_basinhopping: Type[ScipyBasinhopping] = ScipyBasinhopping scipy_brute: Type[ScipyBrute] = ScipyBrute @@ -4195,6 +4290,9 @@ class ParallelAlgorithms(AlgoSelection): pygad: Type[Pygad] = Pygad pygmo_gaco: Type[PygmoGaco] = PygmoGaco pygmo_pso_gen: Type[PygmoPsoGen] = PygmoPsoGen + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_brute: Type[ScipyBrute] = ScipyBrute scipy_differential_evolution: Type[ScipyDifferentialEvolution] = ( ScipyDifferentialEvolution @@ -4296,6 +4394,9 @@ class Algorithms(AlgoSelection): pygmo_sga: Type[PygmoSga] = PygmoSga pygmo_simulated_annealing: Type[PygmoSimulatedAnnealing] = PygmoSimulatedAnnealing pygmo_xnes: Type[PygmoXnes] = PygmoXnes + pyswarms_general: Type[PySwarmsGeneralPSO] = PySwarmsGeneralPSO + pyswarms_global_best: Type[PySwarmsGlobalBestPSO] = PySwarmsGlobalBestPSO + pyswarms_local_best: Type[PySwarmsLocalBestPSO] = PySwarmsLocalBestPSO scipy_bfgs: Type[ScipyBFGS] = ScipyBFGS scipy_basinhopping: Type[ScipyBasinhopping] = ScipyBasinhopping scipy_brute: Type[ScipyBrute] = ScipyBrute diff --git a/src/optimagic/config.py b/src/optimagic/config.py index 5d8563502..0aeb98b6b 100644 --- a/src/optimagic/config.py +++ b/src/optimagic/config.py @@ -55,6 +55,7 @@ def _is_installed(module_name: str) -> bool: IS_BAYESOPT_INSTALLED = _is_installed("bayes_opt") IS_GRADIENT_FREE_OPTIMIZERS_INSTALLED = _is_installed("gradient_free_optimizers") IS_PYGAD_INSTALLED = _is_installed("pygad") +IS_PYSWARMS_INSTALLED = _is_installed("pyswarms") # ====================================================================================== # Check Available Visualization Packages diff --git a/src/optimagic/optimization/algo_options.py b/src/optimagic/optimization/algo_options.py index 41d4e46b0..1d225aa26 100644 --- a/src/optimagic/optimization/algo_options.py +++ b/src/optimagic/optimization/algo_options.py @@ -95,6 +95,7 @@ """ + CONVERGENCE_SECOND_BEST_FTOL_ABS = 1e-08 """float: absolute criterion tolerance optimagic requires if no other stopping criterion apart from max iterations etc. is available @@ -117,6 +118,7 @@ Used in population-based algorithms like genetic algorithms. To disable, set to None. + """ CONVERGENCE_GENERATIONS_NOIMPROVE = None @@ -125,6 +127,7 @@ Used in population-based algorithms like genetic algorithms. To disable, set to None. + """ diff --git a/src/optimagic/optimizers/pyswarms_optimizers.py b/src/optimagic/optimizers/pyswarms_optimizers.py new file mode 100644 index 000000000..38fdd49f9 --- /dev/null +++ b/src/optimagic/optimizers/pyswarms_optimizers.py @@ -0,0 +1,724 @@ +"""Implement PySwarms particle swarm optimization algorithms. + +This module provides optimagic-compatible wrappers for PySwarms particle swarm +optimization algorithms including global best, local best, and general PSO variants with +support for different topologies. + +""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass +from typing import Any, Callable, Literal + +import numpy as np +from numpy.typing import NDArray + +from optimagic import mark +from optimagic.config import IS_PYSWARMS_INSTALLED +from optimagic.exceptions import NotInstalledError +from optimagic.optimization.algorithm import Algorithm, InternalOptimizeResult +from optimagic.optimization.internal_optimization_problem import ( + InternalBounds, + InternalOptimizationProblem, +) +from optimagic.typing import ( + AggregationLevel, + NonNegativeFloat, + PositiveFloat, + PositiveInt, + PyTree, +) + +PYSWARMS_NOT_INSTALLED_ERROR = ( + "This optimizer requires the 'pyswarms' package to be installed. " + "You can install it with `pip install pyswarms`. " + "Visit https://pyswarms.readthedocs.io/en/latest/installation.html " + "for more detailed installation instructions." +) + + +# ====================================================================================== +# 1. Topology Dataclasses +# ====================================================================================== + + +@dataclass(frozen=True) +class Topology: + """Base class for all topology configurations.""" + + +@dataclass(frozen=True) +class StarTopology(Topology): + """Star topology configuration. + + All particles are connected to the global best. + + """ + + +@dataclass(frozen=True) +class RingTopology(Topology): + """Ring topology configuration. + + Particles are connected in a ring structure. + + """ + + k_neighbors: PositiveInt = 3 + """Number of neighbors for each particle.""" + + p_norm: Literal[1, 2] = 2 + """Distance metric for neighbor selection: 1 (Manhattan), 2 (Euclidean).""" + + static: bool = False + """Whether to use a static or dynamic ring topology. + + When True, the neighborhood structure is fixed throughout optimization. When False, + neighbors are recomputed at each iteration based on current particle positions. + + """ + + +@dataclass(frozen=True) +class VonNeumannTopology(Topology): + """Von Neumann topology configuration. + + Particles are arranged on a 2D grid. + + """ + + p_norm: Literal[1, 2] = 2 + """Distance metric for neighbor selection: 1 (Manhattan), 2 (Euclidean).""" + + range_param: PositiveInt = 1 + r"""Range parameter :math:`r` for neighborhood size.""" + + +@dataclass(frozen=True) +class PyramidTopology(Topology): + """Pyramid topology configuration.""" + + static: bool = False + """Whether to use a static or dynamic pyramid topology. + + When True, the neighborhood structure is fixed throughout optimization. When False, + neighbors are recomputed at each iteration based on current particle positions. + + """ + + +@dataclass(frozen=True) +class RandomTopology(Topology): + """Random topology configuration. + + Particles are connected to random neighbors. + + """ + + k_neighbors: PositiveInt = 3 + """Number of neighbors for each particle.""" + + static: bool = False + """Whether to use a static or dynamic random topology. + + When True, the neighborhood structure is fixed throughout optimization. When False, + neighbors are recomputed at each iteration based on current particle positions. + + """ + + +# ====================================================================================== +# Common PSO Options +# ====================================================================================== + + +@dataclass(frozen=True) +class PSOCommonOptions: + """Common options for PySwarms optimizers.""" + + n_particles: PositiveInt = 10 + """Number of particles in the swarm.""" + + cognitive_parameter: PositiveFloat = 0.5 + """Cognitive parameter (c1) - attraction to personal best.""" + + social_parameter: PositiveFloat = 0.3 + """Social parameter (c2) - attraction to neighborhood/global best.""" + + inertia_weight: PositiveFloat = 0.9 + """Inertia weight (w) - momentum control.""" + + stopping_maxiter: PositiveInt = 1000 + """Maximum number of iterations.""" + + initial_positions: list[PyTree] | None = None + """Option to set the initial particle positions. + + If None, positions are generated randomly within the given bounds, or within [0, 1] + if bounds are not specified. + + """ + + oh_strategy: dict[str, str] | None = None + """Dictionary of strategies for time-varying options.""" + + boundary_strategy: Literal[ + "periodic", "reflective", "shrink", "random", "intermediate" + ] = "periodic" + """Strategy for handling out-of-bounds particles. + + Available options: periodic (default), + reflective, shrink, random, intermediate. + + """ + + velocity_strategy: Literal["unmodified", "adjust", "invert", "zero"] = "unmodified" + """Strategy for handling out-of-bounds velocities. + + Available options: unmodified (default), + adjust, invert, zero. + + """ + + velocity_clamp_min: float | None = None + """Minimum velocity limit for particles.""" + + velocity_clamp_max: float | None = None + """Maximum velocity limit for particles.""" + + convergence_ftol_rel: NonNegativeFloat = 0 + """Stop when relative change in objective function is less than this value.""" + + convergence_ftol_iter: PositiveInt = 1 + """Number of iterations to check for convergence.""" + + n_cores: PositiveInt = 1 + """Number of cores for parallel evaluation.""" + + center_init: PositiveFloat = 1.0 + """Scaling factor for initial particle positions.""" + + verbose: bool = False + """Enable or disable the logs and progress bar.""" + + seed: int | None = None + """Random seed for initial positions. + + For full reproducibility, set a global seed with `np.random.seed()`. + + """ + + +# ====================================================================================== +# Algorithm Classes +# ====================================================================================== + + +@mark.minimizer( + name="pyswarms_global_best", + solver_type=AggregationLevel.SCALAR, + is_available=IS_PYSWARMS_INSTALLED, + is_global=True, + needs_jac=False, + needs_hess=False, + needs_bounds=False, + supports_parallelism=True, + supports_bounds=True, + supports_infinite_bounds=False, + supports_linear_constraints=False, + supports_nonlinear_constraints=False, + disable_history=False, +) +@dataclass(frozen=True) +class PySwarmsGlobalBestPSO(Algorithm, PSOCommonOptions): + r"""Minimize a scalar function using Global Best Particle Swarm Optimization. + + A population-based stochastic, global optimization optimization algorithm that + simulates the social behavior of bird flocking or fish schooling. Particles + (candidate solutions) move through the search space, adjusting their positions + based on their own experience (cognitive component) and the experience of their + neighbors or the entire swarm (social component). + + This implementation uses a star topology where all particles are connected to + each other, making each particle aware of the global best solution found by the + entire swarm. + + The position update follows: + + .. math:: + + x_{i}(t+1) = x_{i}(t) + v_{i}(t+1) + + The velocity update follows: + + .. math:: + + v_{ij}(t+1) = w \cdot v_{ij}(t) + c_1 r_{1j}(t)[y_{ij}(t) - x_{ij}(t)] + + c_2 r_{2j}(t)[\hat{y}_j(t) - x_{ij}(t)] + + Where: + - :math:`w`: inertia weight controlling momentum + - :math:`c_1`: cognitive parameter for attraction to personal best + - :math:`c_2`: social parameter for attraction to global best + - :math:`r_{1j}, r_{2j}`: random numbers in [0,1] + - :math:`y_{ij}(t)`: personal best position of particle i + - :math:`\hat{y}_j(t)`: global best position + + This algorithm is an adaptation of the original Particle Swarm Optimization method + by :cite:`Kennedy1995` + + """ + + def _solve_internal_problem( + self, problem: InternalOptimizationProblem, x0: NDArray[np.float64] + ) -> InternalOptimizeResult: + if not IS_PYSWARMS_INSTALLED: + raise NotInstalledError(PYSWARMS_NOT_INSTALLED_ERROR) + + import pyswarms as ps + + pso_options_dict = { + "c1": self.cognitive_parameter, + "c2": self.social_parameter, + "w": self.inertia_weight, + } + optimizer_kwargs = {"options": pso_options_dict} + + res = _pyswarms_internal( + problem=problem, + x0=x0, + optimizer_class=ps.single.GlobalBestPSO, + optimizer_kwargs=optimizer_kwargs, + algo_options=self, + ) + + return res + + +@mark.minimizer( + name="pyswarms_local_best", + solver_type=AggregationLevel.SCALAR, + is_available=IS_PYSWARMS_INSTALLED, + is_global=True, + needs_jac=False, + needs_hess=False, + needs_bounds=False, + supports_parallelism=True, + supports_bounds=True, + supports_infinite_bounds=False, + supports_linear_constraints=False, + supports_nonlinear_constraints=False, + disable_history=False, +) +@dataclass(frozen=True) +class PySwarmsLocalBestPSO(Algorithm, PSOCommonOptions): + r"""Minimize a scalar function using Local Best Particle Swarm Optimization. + + A variant of PSO that uses local neighborhoods instead of a single global best. + Each particle is influenced only by the best position found within its local + neighborhood, which is determined by the k-nearest neighbors using distance metrics. + + This approach uses a ring topology where particles are connected to their local + neighbors, making each particle aware of only the best solution found within its + neighborhood. + + The position update follows: + + .. math:: + + x_{i}(t+1) = x_{i}(t) + v_{i}(t+1) + + The velocity update follows: + + .. math:: + + v_{ij}(t+1) = w \cdot v_{ij}(t) + c_1 r_{1j}(t)[y_{ij}(t) - x_{ij}(t)] + + c_2 r_{2j}(t)[\hat{y}_{lj}(t) - x_{ij}(t)] + + Where: + - :math:`w`: inertia weight controlling momentum + - :math:`c_1`: cognitive parameter for attraction to personal best + - :math:`c_2`: social parameter for attraction to local best + - :math:`r_{1j}, r_{2j}`: random numbers in [0,1] + - :math:`y_{ij}(t)`: personal best position of particle i + - :math:`\hat{y}_{lj}(t)`: local best position in particle i's neighborhood + + The algorithm is based on the original Particle Swarm Optimization method by + :cite:`Kennedy1995` and the local best concept introduced in + :cite:`EberhartKennedy1995`. + + """ + + topology: RingTopology = RingTopology() + """Configuration for the Ring topology. + + This algorithm uses a fixed ring topology where particles are connected to their + local neighbors. This parameter allows customization of the number of neighbors, + distance metric, and whether the topology remains static throughout optimization. + + """ + + def _solve_internal_problem( + self, problem: InternalOptimizationProblem, x0: NDArray[np.float64] + ) -> InternalOptimizeResult: + if not IS_PYSWARMS_INSTALLED: + raise NotInstalledError(PYSWARMS_NOT_INSTALLED_ERROR) + + import pyswarms as ps + + pso_options_dict = { + "c1": self.cognitive_parameter, + "c2": self.social_parameter, + "w": self.inertia_weight, + "k": self.topology.k_neighbors, + "p": self.topology.p_norm, + } + + optimizer_kwargs = { + "options": pso_options_dict, + "static": self.topology.static, + } + + res = _pyswarms_internal( + problem=problem, + x0=x0, + optimizer_class=ps.single.LocalBestPSO, + optimizer_kwargs=optimizer_kwargs, + algo_options=self, + ) + + return res + + +@mark.minimizer( + name="pyswarms_general", + solver_type=AggregationLevel.SCALAR, + is_available=IS_PYSWARMS_INSTALLED, + is_global=True, + needs_jac=False, + needs_hess=False, + needs_bounds=False, + supports_parallelism=True, + supports_bounds=True, + supports_infinite_bounds=False, + supports_linear_constraints=False, + supports_nonlinear_constraints=False, + disable_history=False, +) +@dataclass(frozen=True) +class PySwarmsGeneralPSO(Algorithm, PSOCommonOptions): + r"""Minimize a scalar function using General Particle Swarm Optimization with custom + topologies. + + A flexible PSO implementation that allows selection of different neighborhood + topologies, providing control over the balance between exploration and exploitation. + The topology determines how particles communicate and share information, directly + affecting the algorithm's search behavior. + + The position update follows: + + .. math:: + + x_{i}(t+1) = x_{i}(t) + v_{i}(t+1) + + The velocity update follows: + + .. math:: + + v_{ij}(t+1) = w \cdot v_{ij}(t) + c_1 r_{1j}(t)[y_{ij}(t) - x_{ij}(t)] + + c_2 r_{2j}(t)[\hat{y}_{nj}(t) - x_{ij}(t)] + + Where: + - :math:`w`: inertia weight controlling momentum + - :math:`c_1`: cognitive parameter for attraction to personal best + - :math:`c_2`: social parameter for attraction to neighborhood best + - :math:`r_{1j}, r_{2j}`: random numbers in [0,1] + - :math:`y_{ij}(t)`: personal best position of particle i + - :math:`\hat{y}_{nj}(t)`: neighborhood best position + + This algorithm is based on the original Particle Swarm Optimization method by + :cite:`Kennedy1995` with configurable topology structures. For topology references, + see :cite:`Lane2008SpatialPSO, Ni2013`. + + """ + + topology: Literal["star", "ring", "vonneumann", "random", "pyramid"] | Topology = ( + "star" + ) + """Topology structure for particle communication. + + The `topology` can be specified in two ways: + + 1. **By name (string):** e.g., ``"star"``, ``"ring"``. This uses the default + parameter values for that topology. + 2. **By dataclass instance:** e.g., ``RingTopology(k_neighbors=5, p_norm=1)``. + This allows for detailed configuration of topology-specific parameters. + + Available topologies: ``StarTopology``, ``RingTopology``, ``VonNeumannTopology``, + ``RandomTopology``, ``PyramidTopology``. + + """ + + def _solve_internal_problem( + self, problem: InternalOptimizationProblem, x0: NDArray[np.float64] + ) -> InternalOptimizeResult: + if not IS_PYSWARMS_INSTALLED: + raise NotInstalledError(PYSWARMS_NOT_INSTALLED_ERROR) + + import pyswarms as ps + + pyswarms_topology, topology_options = _resolve_topology_config(self.topology) + base_options = { + "c1": self.cognitive_parameter, + "c2": self.social_parameter, + "w": self.inertia_weight, + } + pso_options_dict = {**base_options, **topology_options} + + optimizer_kwargs = { + "options": pso_options_dict, + "topology": pyswarms_topology, + } + + res = _pyswarms_internal( + problem=problem, + x0=x0, + optimizer_class=ps.single.GeneralOptimizerPSO, + optimizer_kwargs=optimizer_kwargs, + algo_options=self, + ) + + return res + + +def _pyswarms_internal( + problem: InternalOptimizationProblem, + x0: NDArray[np.float64], + optimizer_class: Any, + optimizer_kwargs: dict[str, Any], + algo_options: PSOCommonOptions, +) -> InternalOptimizeResult: + """Internal function for PySwarms optimization. + + Args: + problem: Internal optimization problem. + x0: Initial parameter vector. + optimizer_class: PySwarms optimizer class to use. + optimizer_kwargs: Arguments for optimizer class. + algo_options: The PySwarms common options. + + Returns: + InternalOptimizeResult: Internal optimization result. + + """ + if algo_options.seed is not None: + warnings.warn( + "The 'seed' parameter only makes initial particle positions reproducible. " + "For fully deterministic results, set a global seed with " + "'np.random.seed()' before running the optimizer, as other stochastic " + "parts of PySwarms rely on the global numpy random state.", + UserWarning, + ) + + rng = np.random.default_rng(algo_options.seed) + + velocity_clamp = _build_velocity_clamp( + algo_options.velocity_clamp_min, algo_options.velocity_clamp_max + ) + bounds = _get_pyswarms_bounds(problem.bounds) + + if algo_options.initial_positions is not None: + init_pos = np.array( + [ + problem.converter.params_to_internal(position) + for position in algo_options.initial_positions + ] + ) + else: + init_pos = _create_initial_positions( + x0=x0, + n_particles=algo_options.n_particles, + bounds=bounds, + center=algo_options.center_init, + rng=rng, + ) + + optimizer = optimizer_class( + n_particles=algo_options.n_particles, + dimensions=len(x0), + bounds=bounds, + init_pos=init_pos, + velocity_clamp=velocity_clamp, + oh_strategy=algo_options.oh_strategy, + bh_strategy=algo_options.boundary_strategy, + vh_strategy=algo_options.velocity_strategy, + ftol=algo_options.convergence_ftol_rel, + ftol_iter=algo_options.convergence_ftol_iter, + **optimizer_kwargs, + ) + + objective_wrapper = _create_batch_objective(problem, algo_options.n_cores) + + result = optimizer.optimize( + objective_func=objective_wrapper, + iters=algo_options.stopping_maxiter, + verbose=algo_options.verbose, + ) + + res = _process_pyswarms_result(result=result, optimizer=optimizer) + + return res + + +def _resolve_topology_config( + config: Literal["star", "ring", "vonneumann", "random", "pyramid"] | Topology, +) -> tuple[Any, dict[str, float | int]]: + """Resolves the topology config into a pyswarms topology instance and options + dict.""" + from pyswarms.backend.topology import Pyramid, Random, Ring, Star, VonNeumann + + if isinstance(config, str): + default_topologies = { + "star": StarTopology(), + "ring": RingTopology(), + "vonneumann": VonNeumannTopology(), + "random": RandomTopology(), + "pyramid": PyramidTopology(), + } + if config not in default_topologies: + raise ValueError(f"Unknown topology string: '{config}'") + config = default_topologies[config] + + topology_instance: Any + options: dict[str, float | int] = {} + + if isinstance(config, StarTopology): + topology_instance = Star() + elif isinstance(config, RingTopology): + topology_instance = Ring(static=config.static) + options = {"k": config.k_neighbors, "p": config.p_norm} + elif isinstance(config, VonNeumannTopology): + topology_instance = VonNeumann() + options = {"p": config.p_norm, "r": config.range_param} + elif isinstance(config, RandomTopology): + topology_instance = Random(static=config.static) + options = {"k": config.k_neighbors} + elif isinstance(config, PyramidTopology): + topology_instance = Pyramid(static=config.static) + else: + raise TypeError(f"Unsupported topology configuration type: {type(config)}") + + return topology_instance, options + + +def _build_velocity_clamp( + velocity_clamp_min: float | None, velocity_clamp_max: float | None +) -> tuple[float, float] | None: + """Build velocity clamp tuple.""" + clamp = None + if velocity_clamp_min is not None and velocity_clamp_max is not None: + clamp = (velocity_clamp_min, velocity_clamp_max) + return clamp + + +def _get_pyswarms_bounds( + bounds: InternalBounds, +) -> tuple[NDArray[np.float64], NDArray[np.float64]] | None: + """Convert optimagic bounds to PySwarms format.""" + pyswarms_bounds = None + + if bounds.lower is not None and bounds.upper is not None: + if not np.all(np.isfinite(bounds.lower)) or not np.all( + np.isfinite(bounds.upper) + ): + raise ValueError("PySwarms does not support infinite bounds.") + + pyswarms_bounds = (bounds.lower, bounds.upper) + + return pyswarms_bounds + + +def _create_initial_positions( + x0: NDArray[np.float64], + n_particles: int, + bounds: tuple[NDArray[np.float64], NDArray[np.float64]] | None, + center: float, + rng: np.random.Generator, +) -> NDArray[np.float64]: + """Create an initial swarm positions. + + Args: + x0: Initial parameter vector. + n_particles: Number of particles in the swarm. + bounds: Tuple of (lower_bounds, upper_bounds) arrays or None. + center: Scaling factor for initial particle positions around bounds. + rng: NumPy random number generator instance. + + Returns: + Initial positions array of shape (n_particles, n_dimensions) + where each row represents one particle's starting position. + + """ + n_dimensions = len(x0) + if bounds is None: + lower_bounds: NDArray[np.float64] = np.zeros(n_dimensions, dtype=np.float64) + upper_bounds: NDArray[np.float64] = np.ones(n_dimensions, dtype=np.float64) + else: + lower_bounds, upper_bounds = bounds + + # Generate random initial positions within the bounds, scaled by center + init_pos = center * rng.uniform( + low=lower_bounds, high=upper_bounds, size=(n_particles, n_dimensions) + ) + + init_pos = np.clip(init_pos, lower_bounds, upper_bounds) + init_pos[0] = np.clip(x0, lower_bounds, upper_bounds) + + return init_pos + + +def _create_batch_objective( + problem: InternalOptimizationProblem, + n_cores: int, +) -> Callable[[NDArray[np.float64]], NDArray[np.float64]]: + """Return an batch objective function.""" + + def batch_objective(positions: NDArray[np.float64]) -> NDArray[np.float64]: + """Compute objective values for all particles in positions. + + Args: + positions: 2D array of shape (n_particles, n_dimensions) with + particle positions. + + Returns: + 1D array of shape (n_particles,) with objective values. + + """ + arguments = [position for position in positions] + results = problem.batch_fun(arguments, n_cores=n_cores) + + return np.array(results) + + return batch_objective + + +def _process_pyswarms_result( + result: tuple[float, NDArray[np.float64]], optimizer: Any +) -> InternalOptimizeResult: + """Convert PySwarms result to optimagic format.""" + best_cost, best_position = result + n_iterations = len(optimizer.cost_history) + n_particles = optimizer.n_particles + + return InternalOptimizeResult( + x=best_position, + fun=best_cost, + success=True, + message="PySwarms optimization completed", + n_fun_evals=n_particles * n_iterations, + n_jac_evals=0, + n_hess_evals=0, + n_iterations=n_iterations, + ) diff --git a/tests/optimagic/optimizers/test_pyswarms_optimizers.py b/tests/optimagic/optimizers/test_pyswarms_optimizers.py new file mode 100644 index 000000000..02ce85a8c --- /dev/null +++ b/tests/optimagic/optimizers/test_pyswarms_optimizers.py @@ -0,0 +1,191 @@ +"""Test helper functions in PySwarms optimizers.""" + +import numpy as np +import pytest +from numpy.testing import assert_array_equal + +from optimagic.config import IS_PYSWARMS_INSTALLED +from optimagic.optimization.internal_optimization_problem import InternalBounds +from optimagic.optimizers.pyswarms_optimizers import ( + PyramidTopology, + RandomTopology, + RingTopology, + StarTopology, + VonNeumannTopology, + _build_velocity_clamp, + _create_initial_positions, + _get_pyswarms_bounds, + _resolve_topology_config, +) + +RNG = np.random.default_rng(12345) + + +# Test _build_velocity_clamp +def test_build_velocity_clamp_both_values(): + """Test velocity clamp with both min and max values.""" + result = _build_velocity_clamp(-1.0, 1.0) + assert result == (-1.0, 1.0) + + +def test_build_velocity_clamp_partial_values(): + """Test velocity clamp with only one value provided.""" + result = _build_velocity_clamp(-1.0, None) + assert result is None + + result = _build_velocity_clamp(None, 1.0) + assert result is None + + +def test_build_velocity_clamp_none_values(): + """Test velocity clamp with None values.""" + result = _build_velocity_clamp(None, None) + assert result is None + + +# Test _get_pyswarms_bounds +def test_get_pyswarms_bounds_with_both(): + """Test bounds conversion when both lower and upper bounds are provided.""" + bounds = InternalBounds(lower=np.array([-2.0, -3.0]), upper=np.array([5.0, 4.0])) + + result = _get_pyswarms_bounds(bounds) + + assert result is not None + lower, upper = result + assert_array_equal(lower, np.array([-2.0, -3.0])) + assert_array_equal(upper, np.array([5.0, 4.0])) + + +def test_get_pyswarms_bounds_with_none(): + """Test bounds conversion when no bounds are provided.""" + bounds = InternalBounds(lower=None, upper=None) + + result = _get_pyswarms_bounds(bounds) + assert result is None + + +def test_get_pyswarms_bounds_partial_bounds(): + """Test bounds conversion with only one bound provided.""" + # Only lower bounds + bounds = InternalBounds(lower=np.array([1.0, 2.0]), upper=None) + result = _get_pyswarms_bounds(bounds) + assert result is None + + # Only upper bounds + bounds = InternalBounds(lower=None, upper=np.array([3.0, 4.0])) + result = _get_pyswarms_bounds(bounds) + assert result is None + + +def test_get_pyswarms_bounds_with_infinite(): + """Test that infinite bounds raise ValueError.""" + bounds = InternalBounds( + lower=np.array([-np.inf, -1.0]), upper=np.array([1.0, np.inf]) + ) + + with pytest.raises(ValueError, match="PySwarms does not support infinite bounds"): + _get_pyswarms_bounds(bounds) + + +# Test _create_initial_positions +@pytest.mark.parametrize("center", [0.5, 1.0, 2.0]) +def test_create_initial_positions_basic(center): + """Test basic initial positions creation.""" + x0 = np.array([1.0, 2.0]) + n_particles = 5 + bounds = (np.array([-5.0, -5.0]), np.array([5.0, 5.0])) + + init_pos = _create_initial_positions( + x0=x0, n_particles=n_particles, bounds=bounds, center=center, rng=RNG + ) + + assert init_pos.shape == (5, 2) + + assert_array_equal(init_pos[0], x0) + + # Check all particles are within bounds + assert np.all(init_pos >= bounds[0]) + assert np.all(init_pos <= bounds[1]) + + +def test_create_initial_positions_no_bounds(): + """Test initial positions creation with no bounds.""" + x0 = np.array([0.5, 1.5]) + n_particles = 3 + bounds = None + + init_pos = _create_initial_positions( + x0=x0, n_particles=n_particles, bounds=bounds, center=1.0, rng=RNG + ) + + assert init_pos.shape == (3, 2) + + expected_x0 = np.array([0.5, 1.0]) + assert_array_equal(init_pos[0], expected_x0) + + assert np.all(init_pos >= 0.0) + assert np.all(init_pos <= 1.0) + + +@pytest.mark.skipif(not IS_PYSWARMS_INSTALLED, reason="PySwarms not installed") +@pytest.mark.parametrize( + ("topology_string", "expected_class_name", "expected_options"), + [ + ("star", "Star", {}), + ("ring", "Ring", {"k": 3, "p": 2}), + ("vonneumann", "VonNeumann", {"p": 2, "r": 1}), + ("random", "Random", {"k": 3}), + ("pyramid", "Pyramid", {}), + ], +) +def test_resolve_topology_config_by_string( + topology_string, expected_class_name, expected_options +): + """Test topology resolution with string names.""" + topology, options = _resolve_topology_config(topology_string) + + assert topology.__class__.__name__ == expected_class_name + assert options == expected_options + + +@pytest.mark.skipif(not IS_PYSWARMS_INSTALLED, reason="PySwarms not installed") +@pytest.mark.parametrize( + ("config_instance", "expected_class_name", "expected_options"), + [ + (StarTopology(), "Star", {}), + (RingTopology(k_neighbors=5, p_norm=1, static=True), "Ring", {"k": 5, "p": 1}), + ( + VonNeumannTopology(p_norm=1, range_param=2), + "VonNeumann", + {"p": 1, "r": 2}, + ), + (RandomTopology(k_neighbors=4, static=False), "Random", {"k": 4}), + (PyramidTopology(static=True), "Pyramid", {}), + ], +) +def test_resolve_topology_config_by_instance( + config_instance, expected_class_name, expected_options +): + """Test topology resolution with instances.""" + topology, options = _resolve_topology_config(config_instance) + + # Check the class name and options + assert topology.__class__.__name__ == expected_class_name + assert options == expected_options + + if hasattr(config_instance, "static"): + assert topology.static == config_instance.static + + +@pytest.mark.skipif(not IS_PYSWARMS_INSTALLED, reason="PySwarms not installed") +def test_resolve_topology_config_invalid_string(): + """Test topology resolution with invalid string.""" + with pytest.raises(ValueError, match="Unknown topology string: 'invalid'"): + _resolve_topology_config("invalid") + + +@pytest.mark.skipif(not IS_PYSWARMS_INSTALLED, reason="PySwarms not installed") +def test_resolve_topology_config_invalid_type(): + """Test topology resolution with invalid type.""" + with pytest.raises(TypeError, match="Unsupported topology configuration type"): + _resolve_topology_config(123)