├── requirements.txt ├── docs ├── images │ └── exemplary_results.png ├── introduction.rst ├── examples.rst ├── index.rst ├── Makefile ├── make.bat ├── conf.py ├── api_documentation.rst └── getting_started.rst ├── test ├── unit │ ├── data │ │ ├── test_data_1d_0.yaml │ │ ├── test_data_1d_1.yaml │ │ ├── test_data_2d_0.yaml │ │ └── test_data_2d_1.yaml │ └── test_util.py └── integration │ ├── configs │ ├── forrester_ei.yaml │ ├── forrester_ucb.yaml │ ├── neg_forrester_ucb.yaml │ ├── noisy_forrester_ucb.yaml │ ├── three_hump_camel_ei.yaml │ ├── three_hump_camel_ucb.yaml │ └── contextual_forrester_ucb.yaml │ ├── test_client_cpp.test │ ├── test_client_python.test │ ├── test_client_contextual_python.test │ ├── test_client_cpp.cpp │ ├── test_client_python.py │ └── test_client_contextual_python.py ├── action ├── BayesOpt.action ├── ContextualBayesOpt.action ├── BayesOptState.action └── ContextualBayesOptState.action ├── src └── bayesopt4ros │ ├── __init__.py │ ├── util.py │ ├── test_objectives.py │ ├── contextual_bayesopt_server.py │ ├── data_handler.py │ ├── bayesopt_server.py │ ├── contextual_bayesopt.py │ └── bayesopt.py ├── .flake8 ├── setup.py ├── launch ├── bayesopt.launch └── contextual_bayesopt.launch ├── nodes ├── bayesopt_node.py └── contextual_bayesopt_node.py ├── LICENSE ├── .github └── workflows │ ├── documentation_deployment.yml │ └── continuous_integration.yml ├── package.xml ├── .gitignore ├── README.md └── CMakeLists.txt /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy>=1.20 2 | scipy>=1.6 3 | torch>=1.7 4 | botorch==0.5.0 5 | pytest>=6.2 6 | sphinx 7 | sphinx-rtd-theme -------------------------------------------------------------------------------- /docs/images/exemplary_results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IntelligentControlSystems/bayesopt4ros/HEAD/docs/images/exemplary_results.png -------------------------------------------------------------------------------- /test/unit/data/test_data_1d_0.yaml: -------------------------------------------------------------------------------- 1 | train_inputs: 2 | - - 1.0 3 | - - 1.0 4 | - - 1.0 5 | train_targets: 6 | - - 1.0 7 | - - 1.0 8 | - - 1.0 9 | -------------------------------------------------------------------------------- /test/unit/data/test_data_1d_1.yaml: -------------------------------------------------------------------------------- 1 | train_inputs: 2 | - - 1.0 3 | - - 1.0 4 | - - 1.0 5 | train_targets: 6 | - - 1.0 7 | - - 1.0 8 | - - 1.0 9 | -------------------------------------------------------------------------------- /test/unit/data/test_data_2d_0.yaml: -------------------------------------------------------------------------------- 1 | train_inputs: 2 | - - 2.0 3 | - 2.0 4 | - - 2.0 5 | - 2.0 6 | - - 2.0 7 | - 2.0 8 | train_targets: 9 | - - 2.0 10 | - - 2.0 11 | - - 2.0 12 | -------------------------------------------------------------------------------- /test/unit/data/test_data_2d_1.yaml: -------------------------------------------------------------------------------- 1 | train_inputs: 2 | - - 2.0 3 | - 2.0 4 | - - 2.0 5 | - 2.0 6 | - - 2.0 7 | - 2.0 8 | train_targets: 9 | - - 2.0 10 | - - 2.0 11 | - - 2.0 12 | -------------------------------------------------------------------------------- /action/BayesOpt.action: -------------------------------------------------------------------------------- 1 | # Goal: corresponds to the "output value" for BayesOpt 2 | float64 y_new 3 | --- 4 | # Result: corresponds to the new parameters 5 | float64[] x_new 6 | --- 7 | # Feedback: as of now we do not use this 8 | bool feedback -------------------------------------------------------------------------------- /src/bayesopt4ros/__init__.py: -------------------------------------------------------------------------------- 1 | from .bayesopt import BayesianOptimization 2 | from .bayesopt_server import BayesOptServer 3 | from .contextual_bayesopt import ContextualBayesianOptimization 4 | from .contextual_bayesopt_server import ContextualBayesOptServer 5 | -------------------------------------------------------------------------------- /action/ContextualBayesOpt.action: -------------------------------------------------------------------------------- 1 | # Goal: corresponds to the "output value" and "context" 2 | float64 y_new 3 | float64[] c_new 4 | --- 5 | # Result: corresponds to the new parameters 6 | float64[] x_new 7 | --- 8 | # Feedback: as of now we do not use this 9 | bool feedback -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # E203: whitespace before ':' 3 | # F401: imported but unused 4 | ignore = E203 5 | 6 | per-file-ignores = 7 | __init__.py: F401 8 | 9 | max-complexity = 10 10 | max-line-length = 88 11 | exclude = _setup_util.py,docs/conf.py,scratch*.py 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | from catkin_pkg.python_setup import generate_distutils_setup 5 | 6 | setup_args = generate_distutils_setup( 7 | packages=["bayesopt4ros"], 8 | package_dir={"": "src"}, 9 | ) 10 | 11 | setup(**setup_args) 12 | -------------------------------------------------------------------------------- /launch/bayesopt.launch: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/integration/configs/forrester_ei.yaml: -------------------------------------------------------------------------------- 1 | # This configuration file is specific for the Forrester test function. 2 | experiment_name: "example_1d_forrester" 3 | input_dim: 1 4 | acq_func: "EI" 5 | lower_bound: [0.0] 6 | upper_bound: [1.0] 7 | max_iter: 15 8 | n_init: 3 9 | log_dir: "/root/ws/logs/forrester_ei/" 10 | maximize: false -------------------------------------------------------------------------------- /test/integration/configs/forrester_ucb.yaml: -------------------------------------------------------------------------------- 1 | # This configuration file is specific for the Forrester test function. 2 | experiment_name: "example_1d_forrester" 3 | input_dim: 1 4 | acq_func: "UCB" 5 | lower_bound: [0.0] 6 | upper_bound: [1.0] 7 | max_iter: 20 8 | n_init: 1 9 | log_dir: "/root/ws/logs/forrester_ucb/" 10 | maximize: false -------------------------------------------------------------------------------- /test/integration/configs/neg_forrester_ucb.yaml: -------------------------------------------------------------------------------- 1 | # This configuration file is specific for the Forrester test function. 2 | experiment_name: "example_1d_forrester" 3 | input_dim: 1 4 | acq_func: "UCB" 5 | lower_bound: [0.0] 6 | upper_bound: [1.0] 7 | max_iter: 15 8 | n_init: 3 9 | log_dir: "/root/ws/logs/neg_forrester_ucb/" 10 | maximize: true -------------------------------------------------------------------------------- /test/integration/configs/noisy_forrester_ucb.yaml: -------------------------------------------------------------------------------- 1 | # This configuration file is specific for the Forrester test function. 2 | experiment_name: "example_1d_noisy_forrester" 3 | input_dim: 1 4 | acq_func: "UCB" 5 | lower_bound: [0.0] 6 | upper_bound: [1.0] 7 | max_iter: 50 8 | n_init: 3 9 | log_dir: "/root/ws/logs/noisy_forrester_ucb/" 10 | maximize: false -------------------------------------------------------------------------------- /launch/contextual_bayesopt.launch: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/integration/configs/three_hump_camel_ei.yaml: -------------------------------------------------------------------------------- 1 | # This configuration file is specific for the Three-Hump camel test function. 2 | experiment_name: "example_2d_three_hump_camel" 3 | input_dim: 2 4 | acq_func: "EI" 5 | lower_bound: [-2.0, -2.0] 6 | upper_bound: [2.0, 2.0] 7 | max_iter: 50 8 | n_init: 5 9 | log_dir: "/root/ws/logs/three_hump_camel_ei/" 10 | maximize: false -------------------------------------------------------------------------------- /test/integration/configs/three_hump_camel_ucb.yaml: -------------------------------------------------------------------------------- 1 | # This configuration file is specific for the Three-Hump camel test function. 2 | experiment_name: "example_2d_three_hump_camel" 3 | input_dim: 2 4 | acq_func: "UCB" 5 | lower_bound: [-2.0, -2.0] 6 | upper_bound: [2.0, 2.0] 7 | max_iter: 50 8 | n_init: 5 9 | log_dir: "/root/ws/logs/three_hump_camel_ucb/" 10 | maximize: false -------------------------------------------------------------------------------- /action/BayesOptState.action: -------------------------------------------------------------------------------- 1 | # Goal: as of now we do not use this 2 | bool goal 3 | --- 4 | # Result: this contains the BayesOpt state 5 | # 'best' corresponds to observed parameters/outcomes 6 | float64[] x_best 7 | float64 y_best 8 | # 'opt' corresponds to optimum of the posterior mean 9 | float64[] x_opt 10 | float64 f_opt 11 | --- 12 | # Feedback: as of now we do not use this 13 | bool feedback 14 | -------------------------------------------------------------------------------- /test/integration/configs/contextual_forrester_ucb.yaml: -------------------------------------------------------------------------------- 1 | # This configuration file is specific for the contextual Forrester test function. 2 | experiment_name: "example_1d_contextual_forrester" 3 | 4 | input_dim: 1 5 | lower_bound: [0.0] 6 | upper_bound: [1.0] 7 | 8 | context_dim: 1 9 | 10 | acq_func: "UCB" 11 | max_iter: 100 12 | n_init: 3 13 | log_dir: "/root/ws/logs/contextual_forrester_ucb/" 14 | maximize: false -------------------------------------------------------------------------------- /test/integration/test_client_cpp.test: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | Why BayesOpt4ROS 5 | ---------------- 6 | 7 | .. todo:: Explain motivation behind this package 8 | 9 | - minimal effort to include BO into existing projects 10 | - provide state of the art BO results for roboticists 11 | - free people from hand-tuning controller parameters 12 | 13 | 14 | Bayesian Optimization 15 | ------------------------------------- 16 | 17 | .. todo:: Short introduction to Bayesian Optimization 18 | -------------------------------------------------------------------------------- /action/ContextualBayesOptState.action: -------------------------------------------------------------------------------- 1 | # Goal: the context for which to compute x_opt/f_opt 2 | float64[] context 3 | --- 4 | # Result: this contains the ContextualBayesOpt state 5 | # 'best' corresponds to observed parameter/outcome/context 6 | float64[] x_best 7 | float64[] c_best 8 | float64 y_best 9 | # 'opt' corresponds to the optimum of the posterior mean for the given context 10 | float64[] x_opt 11 | float64 f_opt 12 | --- 13 | # Feedback: as of now we do not use this 14 | bool feedback -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | More Examples 2 | ============= 3 | 4 | Adapting the Configuration 5 | -------------------------- 6 | 7 | .. todo:: Showcase an example. 8 | 9 | 10 | Continue from Previous Experiments 11 | ---------------------------------- 12 | 13 | .. todo:: Showcase an example. 14 | 15 | 16 | Direct Policy Search 17 | -------------------- 18 | 19 | .. todo:: Showcase an example. 20 | 21 | 22 | Contextual Bayesian Optimization 23 | -------------------------------- 24 | 25 | .. todo:: Showcase an example. -------------------------------------------------------------------------------- /nodes/bayesopt_node.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import rospy 4 | from bayesopt4ros import BayesOptServer 5 | 6 | if __name__ == "__main__": 7 | try: 8 | config_name = [p for p in rospy.get_param_names() if "bayesopt_config" in p] 9 | config_file = rospy.get_param(config_name[0]) 10 | node = BayesOptServer(config_file=config_file) 11 | node.run() 12 | except KeyError: 13 | rospy.logerr("Could not find the config file.") 14 | except rospy.ROSInterruptException: 15 | pass 16 | -------------------------------------------------------------------------------- /test/integration/test_client_python.test: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /nodes/contextual_bayesopt_node.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import rospy 4 | from bayesopt4ros import ContextualBayesOptServer 5 | 6 | if __name__ == "__main__": 7 | try: 8 | config_name = [p for p in rospy.get_param_names() if "bayesopt_config" in p] 9 | config_file = rospy.get_param(config_name[0]) 10 | node = ContextualBayesOptServer(config_file=config_file) 11 | node.run() 12 | except KeyError: 13 | rospy.logerr("Could not find the config file.") 14 | except rospy.ROSInterruptException: 15 | pass 16 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | BayesOpt4ROS 2 | ============ 3 | 4 | A Bayesian optimization package for the `Robot Operating System (ROS) `_ developed by the `Intelligent Control Systems (ICS) `_ group at ETH Zurich. 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | :caption: Contents: 9 | 10 | introduction.rst 11 | getting_started.rst 12 | examples.rst 13 | api_documentation.rst 14 | 15 | 16 | Indices and tables 17 | ================== 18 | 19 | * :ref:`genindex` 20 | * :ref:`modindex` 21 | 22 | Documentation generated: |today| -------------------------------------------------------------------------------- /test/integration/test_client_contextual_python.test: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | 22 | 23 | github: 24 | @make clean 25 | @make html 26 | @cp -a _build/html/. ../docs/ 27 | @make clean -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Lukas Froehlich 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 | -------------------------------------------------------------------------------- /.github/workflows/documentation_deployment.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | continuous-integration: 9 | runs-on: ubuntu-latest 10 | container: lukasfro/bayesopt4ros:ci 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v2 15 | with: 16 | path: "src/bayesopt4ros/" 17 | 18 | - name: Set up catkin workspace 19 | run: | 20 | . /opt/ros/noetic/setup.sh 21 | catkin_init_workspace 22 | mkdir -p src/bayesopt4ros 23 | 24 | - name: Checkout repository 25 | uses: actions/checkout@v2 26 | with: 27 | path: "src/bayesopt4ros/" 28 | 29 | - name: Build catkin workspace 30 | run: | 31 | . /opt/ros/noetic/setup.sh 32 | catkin_make_isolated 33 | 34 | - name: Build sphinx documentation 35 | run: | 36 | . devel_isolated/setup.sh 37 | cd src/bayesopt4ros/docs 38 | make html 39 | touch _build/html/.nojekyll 40 | 41 | - name: Deploy API documentation 42 | if: success() 43 | uses: crazy-max/ghaction-github-pages@v2 44 | with: 45 | target_branch: gh-pages 46 | build_dir: src/bayesopt4ros/docs/_build/html 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | -------------------------------------------------------------------------------- /package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | bayesopt4ros 4 | 0.0.1 5 | A Bayesian Optimization package for ROS 6 | 7 | Lukas Froehlich 8 | 9 | MIT 10 | 11 | Lukas Froehlich 12 | 13 | message_generation 14 | message_runtime 15 | catkin 16 | actionlib 17 | actionlib_msgs 18 | roscpp 19 | rospy 20 | std_msgs 21 | roslaunch 22 | rostest 23 | actionlib 24 | actionlib_msgs 25 | roscpp 26 | rospy 27 | std_msgs 28 | actionlib 29 | actionlib_msgs 30 | roscpp 31 | rospy 32 | std_msgs 33 | message_generation 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | import os 10 | import sys 11 | 12 | sys.path.insert(0, os.path.abspath("../src/bayesopt4ros")) 13 | sys.path.insert(0, os.path.abspath("../nodes")) 14 | sys.path.insert(0, os.path.abspath("../test/integration")) 15 | 16 | 17 | # -- Project information ----------------------------------------------------- 18 | 19 | project = "BayesOpt4ROS" 20 | copyright = "2021, Lukas Froehlich" 21 | author = "Lukas Froehlich" 22 | 23 | 24 | # -- General configuration --------------------------------------------------- 25 | 26 | extensions = [ 27 | "sphinx.ext.napoleon", 28 | "sphinx.ext.intersphinx", 29 | "sphinx.ext.autodoc", 30 | "sphinx.ext.mathjax", 31 | "sphinx.ext.viewcode", 32 | "sphinx.ext.todo", 33 | "sphinx.ext.autosectionlabel", 34 | ] 35 | 36 | templates_path = ["_templates"] 37 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 38 | 39 | # -- Miscealaneous ----------------------------------------------------------- 40 | todo_include_todos = True 41 | today_fmt = "%A %d %B %Y, %X" 42 | 43 | # -- Options for HTML output ------------------------------------------------- 44 | 45 | html_theme = "sphinx_rtd_theme" 46 | 47 | html_static_path = ["_static"] 48 | -------------------------------------------------------------------------------- /.github/workflows/continuous_integration.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | continuous-integration: 11 | runs-on: ubuntu-latest 12 | container: lukasfro/bayesopt4ros:ci 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | with: 18 | path: "src/bayesopt4ros/" 19 | 20 | - name: Set up catkin workspace 21 | run: | 22 | . /opt/ros/noetic/setup.sh 23 | catkin_init_workspace 24 | mkdir -p src/bayesopt4ros 25 | 26 | - name: Checkout repository 27 | uses: actions/checkout@v2 28 | with: 29 | path: "src/bayesopt4ros/" 30 | 31 | - name: Build catkin workspace 32 | run: | 33 | . /opt/ros/noetic/setup.sh 34 | catkin_make_isolated 35 | 36 | - name: Build sphinx documentation 37 | run: | 38 | . devel_isolated/setup.sh 39 | cd src/bayesopt4ros/docs 40 | make html 41 | touch _build/html/.nojekyll 42 | 43 | - name: Lint with flake8 44 | run: | 45 | flake8 src/bayesopt4ros/ --count --statistics 46 | 47 | - name: Unittest with pytest 48 | run: | 49 | . devel_isolated/setup.sh 50 | pytest src/bayesopt4ros/test/unit/ 51 | 52 | - name: Integration test with rostest 53 | # Note: need to run `catkin_test_results` as this returns 1 for failure 54 | # catkin_make_isolated returns 0 even if tests fail. 55 | run: | 56 | . devel_isolated/setup.sh 57 | catkin_make_isolated -j1 --catkin-make-args run_tests && catkin_test_results 58 | -------------------------------------------------------------------------------- /docs/api_documentation.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | The Server Classes 5 | ------------------ 6 | 7 | .. automodule:: bayesopt_server 8 | .. autoclass:: BayesOptServer 9 | :members: __init__, next_parameter_callback, state_callback, run 10 | 11 | .. automodule:: contextual_bayesopt_server 12 | .. autoclass:: ContextualBayesOptServer 13 | :members: __init__, next_parameter_callback, state_callback, run 14 | 15 | 16 | The Optimizer Classes 17 | --------------------- 18 | 19 | .. automodule:: bayesopt4ros.bayesopt 20 | .. autoclass:: BayesianOptimization 21 | :members: __init__, from_file, next, update_last_goal, get_optimal_parameters, get_best_observation, constant_config_parameters 22 | 23 | .. automodule:: bayesopt4ros.contextual_bayesopt 24 | .. autoclass:: ContextualBayesianOptimization 25 | :members: __init__, from_file, next, update_last_goal, get_optimal_parameters, get_best_observation, constant_config_parameters 26 | 27 | 28 | Utilities 29 | --------- 30 | 31 | .. automodule:: bayesopt4ros.data_handler 32 | .. autoclass:: DataHandler 33 | :members: __init__, from_file, get_xy, set_xy, add_xy, n_data, x_best, y_best, x_best_accumulate, y_best_accumulate 34 | 35 | .. automodule:: bayesopt4ros.util 36 | :members: count_requests, iter_to_string, create_log_dir 37 | .. autoclass:: PosteriorMean 38 | :members: __init__, forward 39 | 40 | Examplary Clients 41 | ----------------- 42 | 43 | .. automodule:: test_client_python 44 | .. autoclass:: ExampleClient 45 | :members: __init__, request_parameter, request_bayesopt_state, run 46 | 47 | .. automodule:: test_client_contextual_python 48 | .. autoclass:: ExampleContextualClient 49 | :members: __init__, request_parameter, request_bayesopt_state, run, sample_context 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | .DS_store 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | # ROS 133 | build_isolated/ 134 | devel_isolated/ 135 | .catkin_workspace 136 | 137 | # VSCode 138 | .vscode 139 | 140 | # Default logging location 141 | logs/ 142 | 143 | # Others 144 | *scratch* 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BayesOpt4ROS 2 | 3 |

4 | 5 | Integration test status badge 6 | 7 | 8 | 9 | Documentation deployment status badge 10 | 11 | 12 | 13 | DOI 14 | 15 |

16 | 17 | A Bayesian Optimisation package for ROS developed by the [Intelligent Control Systems (ICS)](https://idsc.ethz.ch/research-zeilinger.html) group at ETH Zurich. 18 | 19 | ## Documentation 20 | 21 | We provide a official and up-to-date documentation [here](https://intelligentcontrolsystems.github.io/bayesopt4ros/). 22 | 23 | ## Getting started 24 | 25 | For a short tutorial on how to get started with the BayesOpt4ROS package, please see the [official documentation](https://intelligentcontrolsystems.github.io/bayesopt4ros/getting_started.html). 26 | 27 | ## Contributing 28 | 29 | In case you find our package helpful and want to contribute, please either raise an issue or directly make a pull request. 30 | 31 | - We follow the [NumPy convention](https://numpydoc.readthedocs.io/en/latest/format.html) for docstrings 32 | - We use the [Black formatter](https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html) with the `--line-length` option set to `88` 33 | 34 | ## Testing 35 | 36 | To facilitate easier contribution, we have a continuous integration pipeline set up using Github Actions. 37 | To run the tests locally you can use the following commands: 38 | 39 | ### Unit tests 40 | ```bash 41 | pytest src/bayesopt4ros/test/unit/ 42 | ``` 43 | 44 | ### Integration tests 45 | To run all integration tests (this command is executed in the CI pipeline): 46 | ```bash 47 | catkin_make_isolated -j1 --catkin-make-args run_tests && catkin_test_results 48 | ``` 49 | 50 | Or if you want to just run a specific integration test: 51 | ```bash 52 | rostest bayesopt4ros test_client_python 53 | ``` 54 | 55 | ## Citing BayesOpt4ROS 56 | 57 | If you found our package helpful in your research, we would appreciate if you cite it as follows: 58 | ``` 59 | @misc {Froehlich2021BayesOpt4ROS, 60 | author = {Fr\"ohlich, Lukas P. and Carron, Andrea}, 61 | title = {BayesOpt4ROS: A {B}ayesian Optimization package for the Robot Operating System}, 62 | howpublished = {\url{ https://github.com/IntelligentControlSystems/bayesopt4ros}}, 63 | year = {2021}, 64 | note = {DOI 10.5281/zenodo.5560966}, 65 | } 66 | ``` 67 | -------------------------------------------------------------------------------- /src/bayesopt4ros/util.py: -------------------------------------------------------------------------------- 1 | import rospy 2 | 3 | from functools import wraps 4 | from torch import Tensor 5 | from typing import Callable, Optional 6 | 7 | from botorch.acquisition import AnalyticAcquisitionFunction 8 | from botorch.acquisition.objective import ScalarizedObjective 9 | from botorch.models.model import Model 10 | from botorch.utils.transforms import t_batch_mode_transform 11 | 12 | 13 | class PosteriorMean(AnalyticAcquisitionFunction): 14 | """Greedy acquisition function. 15 | 16 | .. note:: Had to overwrite the implementation of BoTorch (version 0.5.0) 17 | because it does provide the `maximize` flag. See the discussion 18 | on Github: https://github.com/pytorch/botorch/issues/875 19 | """ 20 | 21 | def __init__( 22 | self, 23 | model: Model, 24 | objective: Optional[ScalarizedObjective] = None, 25 | maximize: bool = True, 26 | ) -> None: 27 | super().__init__(model, objective=objective) 28 | self.maximize = maximize 29 | 30 | @t_batch_mode_transform(expected_q=1) 31 | def forward(self, X: Tensor) -> Tensor: 32 | """Evaluate the posterior mean on the candidate set X.""" 33 | posterior = self._get_posterior(X=X) 34 | mean = posterior.mean.view(X.shape[:-2]) 35 | if self.maximize: 36 | return mean 37 | else: 38 | return -mean 39 | 40 | 41 | def count_requests(func: Callable) -> Callable: 42 | """Decorator that keeps track of number of requests. 43 | 44 | Parameters 45 | ---------- 46 | func : Callable 47 | The function to be decorated. 48 | 49 | Returns 50 | ------- 51 | Callable 52 | The decorated function. 53 | """ 54 | 55 | @wraps(func) 56 | def wrapper(self, *args, **kwargs): 57 | self.request_count += 1 58 | ret_val = func(self, *args, **kwargs) 59 | return ret_val 60 | 61 | return wrapper 62 | 63 | 64 | def iter_to_string(it, format_spec, separator=", "): 65 | """Represents an iterable (list, tuple, etc.) as a formatted string. 66 | 67 | Parameters 68 | ---------- 69 | it : Iterable 70 | An iterable with numeric elements. 71 | format_spec : str 72 | Format specifier according to 73 | https://docs.python.org/3/library/string.html#format-specification-mini-language 74 | separator : str 75 | String between items of the iterator. 76 | 77 | Returns 78 | ------- 79 | str 80 | The iterable as formatted string. 81 | """ 82 | return separator.join([f"{format(elem, format_spec)}" for elem in it]) 83 | 84 | 85 | def create_log_dir(log_dir): 86 | """Creates a new logging sub-directory with current date and time. 87 | 88 | Parameters 89 | ---------- 90 | log_dir : str 91 | Path to the root logging directory. 92 | 93 | Returns 94 | ------- 95 | str 96 | The final sub-directory path. 97 | """ 98 | import os 99 | import time 100 | 101 | log_dir = os.path.join(log_dir, time.strftime("%Y-%m-%d-%H-%M-%S")) 102 | os.makedirs(log_dir, exist_ok=True) 103 | rospy.loginfo(f"Logging directory: {log_dir}") 104 | 105 | return log_dir 106 | -------------------------------------------------------------------------------- /src/bayesopt4ros/test_objectives.py: -------------------------------------------------------------------------------- 1 | import torch 2 | 3 | from botorch.test_functions import SyntheticTestFunction, ThreeHumpCamel 4 | 5 | 6 | class Forrester(SyntheticTestFunction): 7 | """The Forrester test function for global optimization. 8 | See definition here: https://www.sfu.ca/~ssurjano/forretal08.html 9 | """ 10 | 11 | dim = 1 12 | _bounds = [(0.0, 1.0)] 13 | _optimal_value = -6.021 14 | _optimizers = [(0.757)] 15 | 16 | def evaluate_true(self, X: torch.Tensor) -> torch.Tensor: 17 | return (6.0 * X - 2.0) ** 2 * torch.sin(12.0 * X - 4.0) 18 | 19 | 20 | class ShiftedThreeHumpCamel(ThreeHumpCamel): 21 | """The Three-Hump Camel test function for global optimization. 22 | 23 | See definition here: https://www.sfu.ca/~ssurjano/camel3.html 24 | 25 | .. note:: We shift the inputs as the initial design would hit the optimum directly. 26 | """ 27 | 28 | _optimizers = [(0.5, 0.5)] 29 | 30 | def evaluate_true(self, X: torch.Tensor) -> torch.Tensor: 31 | return super().evaluate_true(X - 0.5) 32 | 33 | 34 | class ContextualForrester(Forrester): 35 | """Inspired by 'Modifications and Alternative Forms' section on the Forrester 36 | function (https://www.sfu.ca/~ssurjano/forretal08.html), this class extends 37 | the standard Forrester function by a linear trend with varying slope. The 38 | slope defines the context and is between 0 (original) and 20. 39 | 40 | See the location and value of the optimum for different contexts below. 41 | 42 | c = 0.0: g_opt = -6.021, x_opt = 0.757 43 | c = 2.0: g_opt = -5.508, x_opt = 0.755 44 | c = 4.0: g_opt = -4.999, x_opt = 0.753 45 | c = 6.0: g_opt = -4.494, x_opt = 0.751 46 | c = 8.0: g_opt = -3.993, x_opt = 0.749 47 | c = 10.0: g_opt = -4.705, x_opt = 0.115 48 | c = 12.0: g_opt = -5.480, x_opt = 0.110 49 | c = 14.0: g_opt = -6.264, x_opt = 0.106 50 | c = 16.0: g_opt = -7.057, x_opt = 0.101 51 | c = 18.0: g_opt = -7.859, x_opt = 0.097 52 | c = 20.0: g_opt = -8.670, x_opt = 0.092 53 | """ 54 | 55 | dim = 2 56 | input_dim = 1 57 | context_dim = 1 58 | 59 | _bounds = [(0.0, 1.0), (0.0, 20.0)] 60 | 61 | # I'll defined for a contextual problem 62 | _optimal_value = None 63 | _optimizers = None 64 | 65 | # Define optimizer for specific context values 66 | _test_contexts = [ 67 | [0.0], 68 | [2.0], 69 | [4.0], 70 | [6.0], 71 | [8.0], 72 | [10.0], 73 | [12.0], 74 | [14.0], 75 | [16.0], 76 | [18.0], 77 | [20.0], 78 | ] 79 | _contextual_optimizer = [ 80 | [0.757], 81 | [0.755], 82 | [0.753], 83 | [0.751], 84 | [0.749], 85 | [0.115], 86 | [0.110], 87 | [0.106], 88 | [0.101], 89 | [0.097], 90 | [0.092], 91 | ] 92 | _contextual_optimal_values = [ 93 | -6.021, 94 | -5.508, 95 | -4.999, 96 | -4.494, 97 | -3.993, 98 | -4.705, 99 | -5.480, 100 | -6.264, 101 | -7.057, 102 | -7.859, 103 | -8.670, 104 | ] 105 | 106 | def evaluate_true(self, X: torch.Tensor) -> torch.Tensor: 107 | return super().evaluate_true(X[:, 0]) + X[:, 1] * (X[:, 0] - 0.5) 108 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.0.2) 2 | project(bayesopt4ros) 3 | 4 | ## Compile as C++11, supported in ROS Kinetic and newer 5 | add_compile_options(-std=c++11) 6 | 7 | ## Find catkin macros and libraries 8 | ## if COMPONENTS list like find_package(catkin REQUIRED COMPONENTS xyz) 9 | ## is used, also find other catkin packages 10 | find_package(catkin REQUIRED COMPONENTS 11 | actionlib 12 | actionlib_msgs 13 | roscpp 14 | rospy 15 | std_msgs 16 | message_generation 17 | roslaunch 18 | rostest 19 | ) 20 | 21 | ## Uncomment this if the package has a setup.py. This macro ensures 22 | ## modules and global scripts declared therein get installed 23 | ## See http://ros.org/doc/api/catkin/html/user_guide/setup_dot_py.html 24 | catkin_python_setup() 25 | 26 | ################################################ 27 | ## Declare ROS messages, services and actions ## 28 | ################################################ 29 | 30 | ## Generate actions in the 'action' folder 31 | add_action_files( 32 | DIRECTORY action 33 | FILES 34 | BayesOpt.action 35 | BayesOptState.action 36 | ContextualBayesOpt.action 37 | ContextualBayesOptState.action 38 | ) 39 | 40 | ## Generate added messages and services with any dependencies listed here 41 | generate_messages( 42 | DEPENDENCIES 43 | actionlib_msgs 44 | std_msgs 45 | ) 46 | 47 | ################################### 48 | ## catkin specific configuration ## 49 | ################################### 50 | 51 | catkin_package( 52 | CATKIN_DEPENDS actionlib actionlib_msgs roscpp rospy std_msgs message_runtime 53 | ) 54 | 55 | ########### 56 | ## Build ## 57 | ########### 58 | 59 | include_directories( 60 | ${catkin_INCLUDE_DIRS} 61 | ) 62 | 63 | ############# 64 | ## Install ## 65 | ############# 66 | 67 | catkin_install_python(PROGRAMS 68 | nodes/bayesopt_node.py 69 | nodes/contextual_bayesopt_node.py 70 | test/integration/test_client_python.py 71 | test/integration/test_client_contextual_python.py 72 | DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION} 73 | ) 74 | 75 | ############# 76 | ## Testing ## 77 | ############# 78 | 79 | # Add gtest based cpp test target and link libraries 80 | add_rostest_gtest(test_client_cpp test/integration/test_client_cpp.test test/integration/test_client_cpp.cpp) 81 | target_link_libraries(test_client_cpp ${catkin_LIBRARIES}) 82 | 83 | # Add folders to be run by python 84 | # Note: I have not found a way to make this more automatic. 85 | 86 | # Upper Confidence Bound 87 | add_rostest(test/integration/test_client_python.test ARGS 88 | objective:=Forrester 89 | bayesopt_config:=${PROJECT_SOURCE_DIR}/test/integration/configs/forrester_ucb.yaml) 90 | add_rostest(test/integration/test_client_python.test ARGS 91 | objective:=ThreeHumpCamel 92 | bayesopt_config:=${PROJECT_SOURCE_DIR}/test/integration/configs/three_hump_camel_ucb.yaml) 93 | 94 | # Expected Improvement 95 | add_rostest(test/integration/test_client_python.test ARGS 96 | objective:=Forrester 97 | bayesopt_config:=${PROJECT_SOURCE_DIR}/test/integration/configs/forrester_ei.yaml) 98 | add_rostest(test/integration/test_client_python.test ARGS 99 | objective:=ThreeHumpCamel 100 | bayesopt_config:=${PROJECT_SOURCE_DIR}/test/integration/configs/three_hump_camel_ei.yaml) 101 | 102 | # Negating the objective 103 | add_rostest(test/integration/test_client_python.test ARGS 104 | objective:=NegativeForrester 105 | bayesopt_config:=${PROJECT_SOURCE_DIR}/test/integration/configs/neg_forrester_ucb.yaml) 106 | 107 | # Noisy objective with UCB 108 | add_rostest(test/integration/test_client_python.test ARGS 109 | objective:=NoisyForrester 110 | bayesopt_config:=${PROJECT_SOURCE_DIR}/test/integration/configs/noisy_forrester_ucb.yaml) 111 | 112 | # Contextual setting 113 | add_rostest(test/integration/test_client_contextual_python.test ARGS 114 | objective:=ContextualForrester 115 | bayesopt_config:=${PROJECT_SOURCE_DIR}/test/integration/configs/contextual_forrester_ucb.yaml) 116 | -------------------------------------------------------------------------------- /src/bayesopt4ros/contextual_bayesopt_server.py: -------------------------------------------------------------------------------- 1 | import actionlib # type: ignore 2 | import rospy 3 | 4 | from bayesopt4ros import ContextualBayesianOptimization, BayesOptServer, util 5 | from bayesopt4ros.msg import ( # type: ignore 6 | ContextualBayesOptResult, 7 | ContextualBayesOptAction, 8 | ) 9 | from bayesopt4ros.msg import ( # type: ignore 10 | ContextualBayesOptStateResult, 11 | ContextualBayesOptStateAction, 12 | ) 13 | 14 | 15 | class ContextualBayesOptServer(BayesOptServer): 16 | """The contextual Bayesian optimization server node. 17 | 18 | Acts as a layer between the actual contextual Bayesian optimization and ROS. 19 | """ 20 | 21 | def __init__( 22 | self, 23 | config_file: str, 24 | server_name: str = "ContextualBayesOpt", 25 | log_file: str = None, 26 | anonymous: bool = True, 27 | log_level: int = rospy.INFO, 28 | silent: bool = False, 29 | node_rate: float = 5.0, 30 | ) -> None: 31 | """The ContextualBayesOptServer class initializer. 32 | 33 | For paramters see :class:`bayesopt_server.BayesOptServer`. 34 | """ 35 | rospy.logdebug("Initializing Contextual BayesOpt Server") 36 | super().__init__( 37 | config_file=config_file, 38 | server_name=server_name, 39 | log_file=log_file, 40 | anonymous=anonymous, 41 | log_level=log_level, 42 | silent=silent, 43 | node_rate=node_rate, 44 | ) 45 | 46 | rospy.logdebug("[ContextualBayesOptServer] Initialization done") 47 | 48 | @util.count_requests 49 | def next_parameter_callback(self, goal: ContextualBayesOptAction) -> None: 50 | self._print_goal(goal) if not self.silent else None 51 | if self._check_final_iter(goal): 52 | return # Do not continue once we reached maximum iterations 53 | 54 | # Obtain the new parameter values. 55 | result = ContextualBayesOptResult() 56 | result.x_new = list(self.bo.next(goal)) 57 | self.parameter_server.set_succeeded(result) 58 | self._print_result(result) if not self.silent else None 59 | 60 | def state_callback(self, goal: ContextualBayesOptStateAction) -> None: 61 | state = ContextualBayesOptStateResult() 62 | 63 | # Best observed variables 64 | x_best, c_best, y_best = self.bo.get_best_observation() 65 | state.x_best = list(x_best) 66 | state.c_best = list(c_best) 67 | state.y_best = y_best 68 | 69 | # Posterior mean optimum for a given context 70 | x_opt, f_opt = self.bo.get_optimal_parameters(goal.context) 71 | state.x_opt = list(x_opt) 72 | state.f_opt = f_opt 73 | 74 | self.state_server.set_succeeded(state) 75 | 76 | def _initialize_bayesopt(self, config_file): 77 | try: 78 | self.bo = ContextualBayesianOptimization.from_file(config_file) 79 | except Exception as e: 80 | rospy.logerr( 81 | f"[ContextualBayesOpt] Something went wrong with initialization: '{e}'" 82 | ) 83 | rospy.signal_shutdown("Initialization of ContextualBayesOpt failed.") 84 | 85 | def _initialize_parameter_server(self, server_name): 86 | """This server obtains new function values and provides new parameters.""" 87 | self.parameter_server = actionlib.SimpleActionServer( 88 | server_name, 89 | ContextualBayesOptAction, 90 | execute_cb=self.next_parameter_callback, 91 | auto_start=False, 92 | ) 93 | 94 | def _initialize_state_server(self, server_name): 95 | """This server provides the current state/results of BO.""" 96 | self.state_server = actionlib.SimpleActionServer( 97 | server_name, 98 | ContextualBayesOptStateAction, 99 | execute_cb=self.state_callback, 100 | auto_start=False, 101 | ) 102 | 103 | def _print_goal(self, goal): 104 | if not self.request_count == 1: 105 | s = self._log_prefix + f"y_n: {goal.y_new:.3f}" 106 | s += f", c_(n+1) = {util.iter_to_string(goal.c_new, '.3f')}" 107 | rospy.loginfo(s) 108 | else: 109 | rospy.loginfo(self._log_prefix + f"Discard value: {goal.y_new:.3f}") 110 | -------------------------------------------------------------------------------- /test/unit/test_util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import numpy as np 4 | import os 5 | import pytest 6 | import torch 7 | 8 | from botorch.exceptions import BotorchTensorDimensionError 9 | from botorch.utils.containers import TrainingData 10 | from scipy.optimize import Bounds 11 | 12 | from bayesopt4ros.data_handler import DataHandler 13 | 14 | 15 | @pytest.fixture(params=[1, 3, 10]) 16 | def test_data(request): 17 | """Set up a simple dataset to test the DataHandler class. The dimensionality 18 | of the input data is specified by the fixture parameters.""" 19 | dim, n = request.param, 1000 20 | 21 | x = torch.rand(n, dim) * 10 - 5 22 | y = 3 + 0.5 * torch.randn(n, 1) 23 | return TrainingData(Xs=x, Ys=y) 24 | 25 | 26 | def test_data_handling(test_data): 27 | dim = test_data.Xs.shape[1] 28 | bounds = Bounds(lb=-5 * np.ones((dim,)), ub=5 * np.ones((dim,))) 29 | 30 | # Using initilizer for setting data 31 | dh = DataHandler(x=test_data.Xs, y=test_data.Ys) 32 | x, y = dh.get_xy() 33 | np.testing.assert_array_equal(x, test_data.Xs) 34 | np.testing.assert_array_equal(y, test_data.Ys) 35 | 36 | d = dh.get_xy(as_dict=True) 37 | np.testing.assert_array_equal(d["train_inputs"], test_data.Xs) 38 | np.testing.assert_array_equal(d["train_targets"], test_data.Ys) 39 | 40 | # Using setter for setting data 41 | dh = DataHandler(bounds) 42 | np.testing.assert_equal(dh.n_data, 0) 43 | dh.set_xy(x=test_data.Xs, y=test_data.Ys) 44 | 45 | x, y = dh.get_xy() 46 | np.testing.assert_array_equal(x, test_data.Xs) 47 | np.testing.assert_array_equal(y, test_data.Ys) 48 | 49 | d = dh.get_xy(as_dict=True) 50 | np.testing.assert_array_equal(d["train_inputs"], test_data.Xs) 51 | np.testing.assert_array_equal(d["train_targets"], test_data.Ys) 52 | 53 | 54 | def test_adding_data(test_data): 55 | dim = test_data.Xs.shape[1] 56 | 57 | # Single data point 58 | dh = DataHandler(x=test_data.Xs, y=test_data.Ys) 59 | x_new, y_new = torch.rand(1, dim), torch.randn(1, 1) 60 | dh.add_xy(x=x_new, y=y_new) 61 | x, y = dh.get_xy() 62 | np.testing.assert_array_equal(x, torch.cat((test_data.Xs, x_new))) 63 | np.testing.assert_array_equal(y, torch.cat((test_data.Ys, y_new))) 64 | np.testing.assert_equal(dh.n_data, test_data.Xs.shape[0] + 1) 65 | np.testing.assert_equal(len(dh), test_data.Xs.shape[0] + 1) 66 | 67 | # Multiple data points 68 | dh = DataHandler(x=test_data.Xs, y=test_data.Ys) 69 | x_new, y_new = torch.rand(10, dim), torch.randn(10, 1) 70 | dh.add_xy(x=x_new, y=y_new) 71 | x, y = dh.get_xy() 72 | np.testing.assert_array_equal(x, torch.cat((test_data.Xs, x_new))) 73 | np.testing.assert_array_equal(y, torch.cat((test_data.Ys, y_new))) 74 | np.testing.assert_equal(dh.n_data, test_data.Xs.shape[0] + 10) 75 | np.testing.assert_equal(len(dh), test_data.Xs.shape[0] + 10) 76 | 77 | # Adding to empty DataHandler 78 | dh = DataHandler() 79 | x_new, y_new = torch.rand(1, dim), torch.randn(1, 1) 80 | dh.add_xy(x=x_new, y=y_new) 81 | x, y = dh.get_xy() 82 | np.testing.assert_array_equal(x, x_new) 83 | np.testing.assert_array_equal(y, y_new) 84 | np.testing.assert_equal(dh.n_data, 1) 85 | np.testing.assert_equal(len(dh), 1) 86 | 87 | 88 | def test_wrong_inputs(test_data): 89 | # Unequal number of inputs/outputs 90 | with pytest.raises(BotorchTensorDimensionError): 91 | DataHandler(x=test_data.Xs[:5], y=test_data.Ys[:6]) 92 | 93 | 94 | def test_from_single_file(): 95 | dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data") 96 | for dim in [1, 2]: 97 | data_file = os.path.join(dir, f"test_data_{dim}d_0.yaml") 98 | dh = DataHandler.from_file(data_file) 99 | x, y = dh.get_xy() 100 | np.testing.assert_array_equal(x, dim * torch.ones(3, dim)) 101 | np.testing.assert_array_equal(y, dim * torch.ones(3, 1)) 102 | 103 | 104 | def test_from_multiple_files(): 105 | dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data") 106 | for dim in [1, 2]: 107 | data_files = [ 108 | os.path.join(dir, f"test_data_{dim}d_{i}.yaml") for i in [0, 1, 2] 109 | ] 110 | dh = DataHandler.from_file(data_files) 111 | x, y = dh.get_xy() 112 | np.testing.assert_array_equal(x, dim * torch.ones(max(3 * dim, 6), dim)) 113 | np.testing.assert_array_equal(y, dim * torch.ones(max(3 * dim, 6), 1)) 114 | 115 | 116 | def test_from_incompatible_files(): 117 | dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data") 118 | data_files = [ 119 | os.path.join(dir, "test_data_1d_0.yaml"), 120 | os.path.join(dir, "test_data_2d_0.yaml"), 121 | ] 122 | 123 | with pytest.raises(BotorchTensorDimensionError): 124 | DataHandler.from_file(data_files) 125 | -------------------------------------------------------------------------------- /test/integration/test_client_cpp.cpp: -------------------------------------------------------------------------------- 1 | #include "actionlib/client/simple_action_client.h" 2 | #include "math.h" 3 | #include "ros/ros.h" 4 | #include "unistd.h" 5 | #include "gtest/gtest.h" 6 | 7 | #include "bayesopt4ros/BayesOptAction.h" 8 | 9 | using namespace bayesopt4ros; 10 | typedef actionlib::SimpleActionClient Client; 11 | 12 | 13 | std::string vecToString(const std::vector &v, int precision) { 14 | /*! Small helper to get a string representation from a numeric vector. 15 | 16 | Inspiration from: https://github.com/bcohen/leatherman/blob/master/src/print.cpp 17 | 18 | @param v Vector to be converted to a string. 19 | @param precision Number of decimal points to which numbers are shown. 20 | 21 | @return String representation of the vector. 22 | 23 | */ 24 | std::stringstream ss; 25 | ss << "["; 26 | for(std::size_t i = 0; i < v.size(); ++i) { 27 | ss << std::fixed << std::setw(precision) << std::setprecision(precision) << std::showpoint << v[i]; 28 | if (i < v.size() - 1) ss << ", "; 29 | } 30 | ss << "]"; 31 | return ss.str(); 32 | } 33 | 34 | double forresterFunction(const std::vector& x) { 35 | /*! The Forrester test function for global optimization. 36 | 37 | See definition here: https://www.sfu.ca/~ssurjano/forretal08.html 38 | 39 | Note: We multiply by -1 to maximize the function instead of minimizing. 40 | 41 | @param x Input to the function. 42 | 43 | @return Function value at given inputs. 44 | */ 45 | double x0 = x[0]; 46 | return pow(6.0 * x0 - 2.0, 2) * sin(12.0 * x0 - 4.0); 47 | } 48 | 49 | 50 | class ExampleClient { 51 | // ! A demonstration on how to use the BayesOpt server from a C++ node. 52 | public: 53 | ExampleClient(std::string server_name) : client_node_(server_name) { 54 | /*! Constructor of the client that queries the BayesOpt server. 55 | 56 | @param server_name Name of the server (needs to be consistent with server node). 57 | */ 58 | ros::NodeHandle nh; 59 | client_node_.waitForServer(); 60 | 61 | // First value is just to trigger the server 62 | BayesOptGoal goal; 63 | goal.y_new = 0.0; 64 | 65 | // Need boost::bind to pass in the 'this' pointer 66 | // For details see this tutorial: 67 | // http://wiki.ros.org/actionlib_tutorials/Tutorials/Writing%20a%20Callback%20Based%20Simple%20Action%20Client 68 | client_node_.sendGoal(goal, boost::bind(&ExampleClient::bayesOptCallback, this, _1, _2)); 69 | } 70 | 71 | void bayesOptCallback(const actionlib::SimpleClientGoalState& state, 72 | const BayesOptResultConstPtr& result) { 73 | 74 | /*! This method is called everytime an iteration of BayesOpt finishes.*/ 75 | x_new_ = result->x_new; 76 | parametersWereUpdated_ = true; 77 | std::string result_string = "[Client] x_new = " + vecToString(x_new_, 3); 78 | ROS_INFO_STREAM(result_string); 79 | } 80 | 81 | bool checkServer() { 82 | /*! Small helper that checks if server is online. If not, shutdown. */ 83 | bool isOnline = client_node_.isServerConnected(); 84 | if (isOnline) return true; 85 | ROS_WARN("[Client] Server seems to be offline. Shutting down."); 86 | ros::shutdown(); 87 | return false; 88 | } 89 | 90 | void run() { 91 | /*! Method that emulates client behavior. */ 92 | if (checkServer() && parametersWereUpdated_) 93 | { 94 | // Emulate experiment by querying the objective function 95 | double y_new = forresterFunction(x_new_); 96 | parametersWereUpdated_ = false; 97 | ROS_INFO("[Client] y_new = %.2f", y_new); 98 | 99 | // Keeping track of best point so far for the integration test 100 | if (y_new < y_best_) { 101 | y_best_ = y_new; 102 | x_best_ = x_new_; 103 | } 104 | 105 | // Send new goal to server 106 | BayesOptGoal goal; 107 | goal.y_new = y_new; 108 | client_node_.sendGoal(goal, boost::bind(&ExampleClient::bayesOptCallback, this, _1, _2)); 109 | } 110 | } 111 | 112 | // Require those for checking the test condition 113 | double y_best_ = std::numeric_limits::min(); 114 | std::vector x_best_; 115 | private: 116 | Client client_node_; 117 | std::string objective_; 118 | bool parametersWereUpdated_ = false; 119 | std::vector x_new_; 120 | }; 121 | 122 | TEST(ClientTestSuite, testForrester) 123 | { 124 | // Create client node 125 | ExampleClient client("BayesOpt"); 126 | ros::Rate loop_rate(1); 127 | size_t iter = 0; 128 | while (ros::ok()) 129 | { 130 | iter++; 131 | if (iter > 25) break; 132 | ros::spinOnce(); 133 | client.run(); 134 | loop_rate.sleep(); 135 | ROS_INFO_STREAM(iter); 136 | } 137 | ros::shutdown(); 138 | 139 | // Be kind w.r.t. precision of solution 140 | ASSERT_NEAR(client.y_best_, -6.021, 1e-2); 141 | ASSERT_NEAR(client.x_best_[0], 0.757, 1e-2); 142 | } 143 | 144 | int main(int argc, char **argv){ 145 | testing::InitGoogleTest(&argc, argv); 146 | ros::init(argc, argv, "tester"); 147 | ros::NodeHandle nh; 148 | return RUN_ALL_TESTS(); 149 | } -------------------------------------------------------------------------------- /src/bayesopt4ros/data_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import rospy 4 | import torch 5 | import yaml 6 | 7 | from torch import Tensor 8 | from typing import Dict, Tuple, Union, List 9 | 10 | from botorch.utils.containers import TrainingData 11 | from botorch.exceptions.errors import BotorchTensorDimensionError 12 | 13 | 14 | class DataHandler(object): 15 | """Helper class that handles all data for BayesOpt. 16 | 17 | .. note:: This is mostly a convenience class to clean up the BO classes. 18 | """ 19 | 20 | def __init__( 21 | self, x: Tensor = None, y: Tensor = None, maximize: bool = True 22 | ) -> None: 23 | """The DataHandler class initializer. 24 | 25 | Parameters 26 | ---------- 27 | x : torch.Tensor 28 | The training inputs. 29 | y : torch.Tensor 30 | The training targets. 31 | maximize : bool 32 | Specifies if 'best' refers to min or max. 33 | """ 34 | self.set_xy(x=x, y=y) 35 | self.maximize = maximize 36 | 37 | @classmethod 38 | def from_file(cls, file: Union[str, List[str]]) -> DataHandler: 39 | """Creates a DataHandler instance with input/target values from the 40 | specified file. 41 | 42 | Parameters 43 | ---------- 44 | file : str or List[str] 45 | One or many evaluation files to load data from. 46 | 47 | Returns 48 | ------- 49 | :class:`DataHandler` 50 | An instance of the DataHandler class. Returns an empty object if 51 | not file could be found. 52 | """ 53 | files = [file] if isinstance(file, str) else file 54 | x, y = [], [] 55 | 56 | for file in files: 57 | try: 58 | with open(file, "r") as f: 59 | data = yaml.load(f, Loader=yaml.FullLoader) 60 | x.append(torch.tensor(data["train_inputs"])) 61 | y.append(torch.tensor(data["train_targets"])) 62 | except FileNotFoundError: 63 | rospy.logwarn(f"The evaluations file '{file}' could not be found.") 64 | 65 | if x and y: 66 | if ( 67 | not len(set([xi.shape[1] for xi in x])) == 1 68 | ): # check for correct dimension 69 | message = "Evaluation points seem to have different dimensions." 70 | raise BotorchTensorDimensionError(message) 71 | x = torch.cat(x) 72 | y = torch.cat(y) 73 | return cls(x=x, y=y) 74 | else: 75 | return cls() 76 | 77 | def get_xy( 78 | self, as_dict: dict = False 79 | ) -> Union[Dict, Tuple[torch.Tensor, torch.Tensor]]: 80 | """Returns the data as a tuple (default) or as a dictionary.""" 81 | if as_dict: 82 | return {"train_inputs": self.data.Xs, "train_targets": self.data.Ys} 83 | else: 84 | return (self.data.Xs, self.data.Ys) 85 | 86 | def set_xy(self, x: Tensor = None, y: Union[float, Tensor] = None): 87 | """Overwrites the existing data.""" 88 | if x is None or y is None: 89 | self.data = TrainingData(Xs=torch.tensor([]), Ys=torch.tensor([])) 90 | else: 91 | if not isinstance(y, Tensor): 92 | y = torch.tensor([[y]]) 93 | self._validate_data_args(x, y) 94 | self.data = TrainingData(Xs=x, Ys=y) 95 | 96 | def add_xy(self, x: Tensor = None, y: Union[float, Tensor] = None): 97 | """Adds new data to the existing data.""" 98 | if not isinstance(y, Tensor): 99 | y = torch.tensor([[y]]) 100 | x = torch.atleast_2d(x) 101 | self._validate_data_args(x, y) 102 | x = torch.cat((self.data.Xs, x)) if self.n_data else x 103 | y = torch.cat((self.data.Ys, y)) if self.n_data else y 104 | self.set_xy(x=x, y=y) 105 | 106 | @property 107 | def n_data(self): 108 | """Number of data points.""" 109 | return self.data.Xs.shape[0] 110 | 111 | @property 112 | def x_best(self): 113 | """Location of the best observed datum.""" 114 | if self.maximize: 115 | return self.data.Xs[torch.argmax(self.data.Ys)] 116 | else: 117 | return self.data.Xs[torch.argmin(self.data.Ys)] 118 | 119 | @property 120 | def x_best_accumulate(self): 121 | """Locations of the best observed datum accumulated along first axis.""" 122 | return self.data.Xs[self.idx_best_accumulate] 123 | 124 | @property 125 | def y_best(self): 126 | """Function value of the best observed datum.""" 127 | if self.maximize: 128 | return torch.max(self.data.Ys) 129 | else: 130 | return torch.min(self.data.Ys) 131 | 132 | @property 133 | def y_best_accumulate(self): 134 | """Function value of the best ovbserved datum accumulated along first axis.""" 135 | return self.data.Ys[self.idx_best_accumulate] 136 | 137 | @property 138 | def idx_best_accumulate(self): 139 | """Indices of the best observed data accumulated along first axis.""" 140 | argminmax = torch.argmax if self.maximize else torch.argmin 141 | return [argminmax(self.data.Ys[: i + 1]).item() for i in range(self.n_data)] 142 | 143 | def __len__(self): 144 | """Such that we can use len(data_handler).""" 145 | return self.n_data 146 | 147 | @staticmethod 148 | def _validate_data_args(x: Tensor, y: Tensor): 149 | """Checks if the dimensions of the training data is correct.""" 150 | if x.dim() != 2: 151 | message = f"Input dimension is assumed 2-dim. not {x.ndim}-dim." 152 | raise BotorchTensorDimensionError(message) 153 | if y.dim() != 2: 154 | message = f"Output dimension is assumed 2-dim. not {x.ndim}-dim." 155 | raise BotorchTensorDimensionError(message) 156 | if y.shape[1] != 1: 157 | message = "We only support 1-dimensional outputs for the moment." 158 | raise BotorchTensorDimensionError(message) 159 | if x.shape[0] != y.shape[0]: 160 | message = "Not the number of input/ouput data." 161 | raise BotorchTensorDimensionError(message) 162 | -------------------------------------------------------------------------------- /src/bayesopt4ros/bayesopt_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import actionlib 4 | import rospy 5 | 6 | 7 | from bayesopt4ros import BayesianOptimization, util 8 | from bayesopt4ros.msg import BayesOptResult, BayesOptAction 9 | from bayesopt4ros.msg import BayesOptStateResult, BayesOptStateAction 10 | 11 | 12 | class BayesOptServer(object): 13 | """The Bayesian optimization server node. 14 | 15 | Acts as a layer between the actual Bayesian optimization and ROS. 16 | """ 17 | 18 | def __init__( 19 | self, 20 | config_file: str, 21 | server_name: str = "BayesOpt", 22 | log_file: str = None, 23 | anonymous: bool = True, 24 | log_level: int = rospy.INFO, 25 | silent: bool = False, 26 | node_rate: float = 5.0, 27 | ) -> None: 28 | """The BayesOptServer class initializer. 29 | 30 | Parameters 31 | ---------- 32 | config_file : str 33 | File that describes all settings for Bayesian optimization. 34 | server_name : str 35 | Name of the server that is used for ROS. 36 | log_file : str 37 | All input/output pairs are logged to this file. 38 | anonymous : bool 39 | Flag if the node should be anonymous or not (see ROS documentation). 40 | log_level : int 41 | Controls the log_level of the node's output. 42 | silent : bool 43 | Controls the verbosity of the node's output. 44 | node_rate : float 45 | Rate at which the server gives feedback. 46 | """ 47 | rospy.init_node( 48 | self.__class__.__name__, 49 | anonymous=anonymous, 50 | log_level=log_level, 51 | ) 52 | 53 | self._initialize_bayesopt(config_file) 54 | self._initialize_parameter_server(server_name) 55 | self._initialize_state_server(server_name + "State") 56 | self.parameter_server.start() 57 | self.state_server.start() 58 | 59 | self.request_count = 0 60 | self.log_file = log_file 61 | self.config_file = config_file 62 | self.silent = silent 63 | self.rosrate = rospy.Rate(node_rate) 64 | rospy.loginfo(self._log_prefix + "Ready to receive requests.") 65 | 66 | @util.count_requests 67 | def next_parameter_callback(self, goal: BayesOptAction) -> None: 68 | """Method that gets called when a new parameter vector is requested. 69 | 70 | The action message (goal/result/feedback) is defined here: 71 | ``action/BayesOpt.action`` 72 | 73 | .. literalinclude:: ../action/BayesOpt.action 74 | 75 | Parameters 76 | ---------- 77 | goal : BayesOptAction 78 | The action (goal) coming from a client. 79 | """ 80 | 81 | self._print_goal(goal) if not self.silent else None 82 | if self._check_final_iter(goal): 83 | return # Do not continue once we reached maximum iterations 84 | 85 | # Obtain the new parameter values. 86 | result = BayesOptResult() 87 | result.x_new = list(self.bo.next(goal)) 88 | self.parameter_server.set_succeeded(result) 89 | self._print_result(result) if not self.silent else None 90 | 91 | def state_callback(self, goal) -> None: 92 | """Method that gets called when the BayesOpt state is requested. 93 | 94 | .. note:: We are calling this `state` instead of `result` to avoid 95 | confusion with the `result` variable in the action message. 96 | 97 | The action message (goal/result/feedback) is defined here: 98 | ``action/BayesOptState.action`` 99 | 100 | .. literalinclude:: ../action/BayesOptState.action 101 | 102 | Parameters 103 | ---------- 104 | goal : BayesOptStateAction 105 | The action (goal) coming from a client. 106 | """ 107 | state = BayesOptStateResult() 108 | 109 | # Best observed variables 110 | x_best, y_best = self.bo.get_best_observation() 111 | state.x_best = list(x_best) 112 | state.y_best = y_best 113 | 114 | # Posterior mean optimum 115 | x_opt, f_opt = self.bo.get_optimal_parameters() 116 | state.x_opt = list(x_opt) 117 | state.f_opt = f_opt 118 | 119 | self.state_server.set_succeeded(state) 120 | 121 | def _initialize_bayesopt(self, config_file): 122 | try: 123 | self.bo = BayesianOptimization.from_file(config_file) 124 | except Exception as e: 125 | rospy.logerr(f"[BayesOpt] Something went wrong with initialization: '{e}'") 126 | rospy.signal_shutdown("Initialization of BayesOpt failed.") 127 | 128 | def _initialize_parameter_server(self, server_name): 129 | """This server obtains new function values and provides new parameters.""" 130 | self.parameter_server = actionlib.SimpleActionServer( 131 | server_name, 132 | BayesOptAction, 133 | execute_cb=self.next_parameter_callback, 134 | auto_start=False, 135 | ) 136 | 137 | def _initialize_state_server(self, server_name): 138 | """This server provides the current state/results of BO.""" 139 | self.state_server = actionlib.SimpleActionServer( 140 | server_name, 141 | BayesOptStateAction, 142 | execute_cb=self.state_callback, 143 | auto_start=False, 144 | ) 145 | 146 | def _check_final_iter(self, goal): 147 | if self.bo.max_iter and self.request_count > self.bo.max_iter: 148 | # Updates model with last function and logs the final GP model 149 | rospy.logwarn("[BayesOpt] Max iter reached. No longer responding!") 150 | self.bo.update_last_goal(goal) 151 | self.parameter_server.set_aborted() 152 | return True 153 | else: 154 | return False 155 | 156 | def _print_goal(self, goal): 157 | if not self.request_count == 1: 158 | rospy.loginfo(self._log_prefix + f"New value: {goal.y_new:.3f}") 159 | else: 160 | rospy.loginfo(self._log_prefix + f"Discard value: {goal.y_new:.3f}") 161 | 162 | def _print_result(self, result): 163 | s = util.iter_to_string(result.x_new, ".3f") 164 | rospy.loginfo(self._log_prefix + f"x_new: [{s}]") 165 | if self.request_count < self.bo.max_iter: 166 | rospy.loginfo(self._log_prefix + "Waiting for new request...") 167 | 168 | @property 169 | def _log_prefix(self) -> str: 170 | """Convenience property that pre-fixes the logging strings.""" 171 | return f"[{self.__class__.__name__}] Iteration {self.request_count}: " 172 | 173 | @staticmethod 174 | def run() -> None: 175 | """Simply starts the server.""" 176 | rospy.spin() 177 | -------------------------------------------------------------------------------- /test/integration/test_client_python.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import actionlib 4 | import itertools 5 | import unittest 6 | import numpy as np 7 | import rospy 8 | import rostest 9 | import torch 10 | 11 | from typing import Callable 12 | 13 | from bayesopt4ros import test_objectives 14 | from bayesopt4ros.msg import BayesOptAction, BayesOptGoal 15 | from bayesopt4ros.msg import BayesOptStateAction, BayesOptStateGoal, BayesOptStateResult 16 | 17 | 18 | class ExampleClient(object): 19 | """A demonstration on how to use the BayesOpt server from a Python node.""" 20 | 21 | def __init__(self, server_name: str, objective: Callable, maximize=True) -> None: 22 | """Initializer of the client that queries the BayesOpt server. 23 | 24 | Parameters 25 | ---------- 26 | server_name : str 27 | Name of the server (needs to be consistent with server node). 28 | objective : str 29 | Name of the example objective. 30 | maximize : bool 31 | If True, consider the problem a maximization problem. 32 | """ 33 | rospy.init_node(self.__class__.__name__, anonymous=True, log_level=rospy.INFO) 34 | self.client = actionlib.SimpleActionClient(server_name, BayesOptAction) 35 | self.client.wait_for_server() 36 | if objective == "Forrester": 37 | self.func = test_objectives.Forrester() 38 | elif objective == "NegativeForrester": 39 | self.func = test_objectives.Forrester(negate=True) 40 | elif objective == "NoisyForrester": 41 | self.func = test_objectives.Forrester(noise_std=0.5) 42 | elif objective == "ThreeHumpCamel": 43 | self.func = test_objectives.ShiftedThreeHumpCamel() 44 | else: 45 | raise ValueError("No such objective.") 46 | self.maximize = maximize 47 | 48 | def request_parameter(self, y_new: float) -> np.ndarray: 49 | """Method that requests new parameters from the BayesOpt server. 50 | 51 | Parameters 52 | ---------- 53 | value : float 54 | The function value obtained from the objective/experiment. 55 | 56 | Returns 57 | ------- 58 | numpy.ndarray 59 | An array containing the new parameters suggested by BayesOpt server. 60 | """ 61 | goal = BayesOptGoal(y_new=y_new) 62 | self.client.send_goal(goal) 63 | self.client.wait_for_result() 64 | result = self.client.get_result() 65 | return result.x_new 66 | 67 | def request_bayesopt_state(self) -> BayesOptStateResult: 68 | """Method that requests the (final) state of BayesOpt server. 69 | 70 | .. note:: As we only call this function once, we can just create the 71 | corresponding client locally. 72 | """ 73 | state_client = actionlib.SimpleActionClient( 74 | "BayesOptState", BayesOptStateAction 75 | ) 76 | state_client.wait_for_server() 77 | 78 | goal = BayesOptStateGoal() 79 | state_client.send_goal(goal) 80 | state_client.wait_for_result() 81 | return state_client.get_result() 82 | 83 | def run(self) -> None: 84 | """Method that emulates client behavior.""" 85 | # First value is just to trigger the server 86 | x_new = self.request_parameter(0.0) 87 | 88 | # Start querying the BayesOpt server until it reached max iterations 89 | for iter in itertools.count(): 90 | rospy.loginfo(f"[Client] Iteration {iter + 1}") 91 | p_string = ", ".join([f"{xi:.3f}" for xi in x_new]) 92 | rospy.loginfo(f"[Client] x_new = [{p_string}]") 93 | 94 | # Emulate experiment by querying the objective function 95 | y_new = self.func(torch.atleast_2d(torch.tensor(x_new))).squeeze().item() 96 | rospy.loginfo(f"[Client] y_new = {y_new:.2f}") 97 | 98 | # Request server and obtain new parameters 99 | x_new = self.request_parameter(y_new) 100 | if not len(x_new): 101 | rospy.loginfo("[Client] Terminating - invalid response from server.") 102 | break 103 | 104 | 105 | class ClientTestCase(unittest.TestCase): 106 | """Integration test cases for exemplary Python client.""" 107 | 108 | _objective_name = None 109 | _maximize = True 110 | 111 | def test_objective(self) -> None: 112 | """Testing the client on the defined objective function.""" 113 | 114 | # Set up the client 115 | node = ExampleClient( 116 | server_name="BayesOpt", 117 | objective=self._objective_name, 118 | maximize=self._maximize, 119 | ) 120 | 121 | # Emulate experiment 122 | node.run() 123 | 124 | # Get the (estimated) optimum of the objective 125 | result = node.request_bayesopt_state() 126 | 127 | # True optimum of the objective 128 | x_opt = np.array(node.func.optimizers[0]) 129 | f_opt = np.array(node.func.optimal_value) 130 | 131 | # Be kind w.r.t. precision of solution 132 | np.testing.assert_almost_equal(result.x_opt, x_opt, decimal=1) 133 | np.testing.assert_almost_equal(result.f_opt, f_opt, decimal=1) 134 | 135 | 136 | class ClientTestCaseForrester(ClientTestCase): 137 | _objective_name = "Forrester" 138 | _maximize = False 139 | 140 | 141 | class ClientTestCaseNegativeForrester(ClientTestCase): 142 | _objective_name = "NegativeForrester" 143 | _maximize = True 144 | 145 | 146 | class ClientTestCaseNoisyForrester(ClientTestCase): 147 | _objective_name = "NoisyForrester" 148 | _maximize = False 149 | 150 | 151 | class ClientTestCaseThreeHumpCamel(ClientTestCase): 152 | _objective_name = "ThreeHumpCamel" 153 | _maximize = False 154 | 155 | 156 | if __name__ == "__main__": 157 | # Note: unfortunately, rostest.rosrun does not allow to parse arguments 158 | # This can probably be done more efficiently but honestly, the ROS documentation for 159 | # integration testing is kind of outdated and not very thorough... 160 | 161 | objective = rospy.get_param("/objective") 162 | rospy.logwarn(f"Objective: {objective}") 163 | if objective == "Forrester": 164 | rostest.rosrun("bayesopt4ros", "test_python_client", ClientTestCaseForrester) 165 | elif objective == "NegativeForrester": 166 | rostest.rosrun( 167 | "bayesopt4ros", "test_python_client", ClientTestCaseNegativeForrester 168 | ) 169 | elif objective == "NoisyForrester": 170 | rostest.rosrun( 171 | "bayesopt4ros", "test_python_client", ClientTestCaseNoisyForrester 172 | ) 173 | elif objective == "ThreeHumpCamel": 174 | rostest.rosrun( 175 | "bayesopt4ros", "test_python_client", ClientTestCaseThreeHumpCamel 176 | ) 177 | else: 178 | raise ValueError("Not a known objective function.") 179 | -------------------------------------------------------------------------------- /test/integration/test_client_contextual_python.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import actionlib 4 | import itertools 5 | import unittest 6 | import numpy as np 7 | import rospy 8 | import rostest 9 | import torch 10 | 11 | from typing import Callable 12 | 13 | from bayesopt4ros import test_objectives 14 | from bayesopt4ros.msg import ContextualBayesOptAction, ContextualBayesOptGoal 15 | from bayesopt4ros.msg import ( 16 | ContextualBayesOptStateAction, 17 | ContextualBayesOptStateGoal, 18 | ContextualBayesOptStateResult, 19 | ) 20 | 21 | 22 | class ExampleContextualClient(object): 23 | """A demonstration on how to use the contexutal BayesOpt server from a Python 24 | node.""" 25 | 26 | def __init__(self, server_name: str, objective: Callable, maximize=True) -> None: 27 | """Initializer of the client that queries the contextual BayesOpt server. 28 | 29 | Parameters 30 | ---------- 31 | server_name : str 32 | Name of the server (needs to be consistent with server node). 33 | objective : str 34 | Name of the example objective. 35 | maximize : bool 36 | If True, consider the problem a maximization problem. 37 | """ 38 | rospy.init_node(self.__class__.__name__, anonymous=True, log_level=rospy.INFO) 39 | self.client = actionlib.SimpleActionClient( 40 | server_name, ContextualBayesOptAction 41 | ) 42 | 43 | self.client.wait_for_server() 44 | 45 | if objective == "ContextualForrester": 46 | self.func = test_objectives.ContextualForrester() 47 | else: 48 | raise ValueError("No such objective.") 49 | 50 | self.maximize = maximize 51 | self.y_best = -np.inf if maximize else np.inf 52 | self.x_best = None 53 | 54 | def request_parameter(self, y_new: float, c_new: np.ndarray) -> np.ndarray: 55 | """Method that requests new parameters from the ContextualBayesOpt 56 | server for a given context. 57 | 58 | Parameters 59 | ---------- 60 | y_new : float 61 | The function value obtained from the objective/experiment. 62 | c_new : np.ndarray 63 | The context variable for the next evaluation/experiment. 64 | 65 | Returns 66 | ------- 67 | numpy.ndarray 68 | An array containing the new parameters suggested by contextual BayesOpt 69 | server. 70 | """ 71 | goal = ContextualBayesOptGoal(y_new=y_new, c_new=c_new) 72 | self.client.send_goal(goal) 73 | self.client.wait_for_result() 74 | result = self.client.get_result() 75 | return torch.tensor(result.x_new) 76 | 77 | def request_bayesopt_state(self, context) -> ContextualBayesOptStateResult: 78 | """Method that requests the (final) state of BayesOpt server. 79 | 80 | .. note:: As we only call this function once, we can just create the 81 | corresponding client locally. 82 | """ 83 | state_client = actionlib.SimpleActionClient( 84 | "ContextualBayesOptState", ContextualBayesOptStateAction 85 | ) 86 | state_client.wait_for_server() 87 | 88 | goal = ContextualBayesOptStateGoal() 89 | goal.context = list(context) 90 | state_client.send_goal(goal) 91 | state_client.wait_for_result() 92 | return state_client.get_result() 93 | 94 | def run(self) -> None: 95 | """Method that emulates client behavior.""" 96 | # First value is just to trigger the server 97 | 98 | c_new = self.sample_context() 99 | x_new = self.request_parameter(y_new=0.0, c_new=c_new) 100 | 101 | # Start querying the BayesOpt server until it reached max iterations 102 | for iter in itertools.count(): 103 | rospy.loginfo(f"[Client] Iteration {iter + 1}") 104 | x_string = ", ".join([f"{xi:.3f}" for xi in x_new]) 105 | c_string = ", ".join([f"{xi:.3f}" for xi in c_new]) 106 | rospy.loginfo(f"[Client] x_new = [{x_string}] for c_new = [{c_string}]") 107 | 108 | # Emulate experiment by querying the objective function 109 | xc_new = torch.atleast_2d(torch.cat((x_new, c_new))) 110 | y_new = self.func(xc_new).squeeze().item() 111 | 112 | if (self.maximize and y_new > self.y_best) or ( 113 | not self.maximize and y_new < self.y_best 114 | ): 115 | self.y_best = y_new 116 | self.x_best = x_new 117 | 118 | rospy.loginfo(f"[Client] y_new = {y_new:.2f}") 119 | 120 | # Request server and obtain new parameters 121 | c_new = self.sample_context() 122 | x_new = self.request_parameter(y_new=y_new, c_new=c_new) 123 | if not len(x_new): 124 | rospy.loginfo("[Client] Terminating - invalid response from server.") 125 | break 126 | 127 | def sample_context(self) -> np.ndarray: 128 | """Samples a random context variable to emulate the client.""" 129 | context_bounds = [b for b in self.func._bounds[self.func.input_dim :]] 130 | context = torch.tensor([np.random.uniform(b[0], b[1]) for b in context_bounds]) 131 | return context 132 | 133 | 134 | class ContextualClientTestCase(unittest.TestCase): 135 | """Integration test cases for exemplary contextual Python client.""" 136 | 137 | _objective_name = None 138 | _maximize = True 139 | 140 | def test_objective(self) -> None: 141 | """Testing the client on the defined objective function and couple of 142 | contexts.""" 143 | 144 | # Set up the client 145 | node = ExampleContextualClient( 146 | server_name="ContextualBayesOpt", 147 | objective=self._objective_name, 148 | maximize=self._maximize, 149 | ) 150 | 151 | # Emulate experiment 152 | node.run() 153 | 154 | # Check the estimated optimum for different contexts 155 | for context, x_opt, f_opt in zip( 156 | node.func._test_contexts, 157 | node.func._contextual_optimizer, 158 | node.func._contextual_optimal_values, 159 | ): 160 | # Get the (estimated) optimum of the objective for a given context 161 | result = node.request_bayesopt_state(context) 162 | 163 | # Be kind w.r.t. precision of solution 164 | np.testing.assert_almost_equal(result.x_opt, x_opt, decimal=1) 165 | np.testing.assert_almost_equal(result.f_opt, f_opt, decimal=1) 166 | 167 | 168 | class ContextualClientTestCaseForrester(ContextualClientTestCase): 169 | _objective_name = "ContextualForrester" 170 | _maximize = False 171 | 172 | 173 | if __name__ == "__main__": 174 | # Note: unfortunately, rostest.rosrun does not allow to parse arguments 175 | # This can probably be done more efficiently but honestly, the ROS documentation for 176 | # integration testing is kind of outdated and not very thorough... 177 | objective = rospy.get_param("/objective") 178 | rospy.logwarn(f"Objective: {objective}") 179 | if objective == "ContextualForrester": 180 | rostest.rosrun( 181 | "bayesopt4ros", "test_python_client", ContextualClientTestCaseForrester 182 | ) 183 | else: 184 | raise ValueError("Not a known objective function.") 185 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | =============== 3 | 4 | Here you find two ways of getting started with the BayesOpt4ROS package. 5 | If you already have a catkin workspace and would like to directly integrate BayesOpt4ROS into your workflow, please see section :ref:`sec-getting-started-own-workspace`. 6 | In case you just want to try out the package, follow :ref:`sec-getting-started-docker` to set up a workspace inside a Docker container. 7 | 8 | 9 | .. _sec-getting-started-own-workspace: 10 | 11 | Your own workspace 12 | ------------------ 13 | 14 | For the following steps, we'll assume that :code:`${WORKSPACE}` is the root of your catkin workspace. 15 | 16 | Requirements 17 | ^^^^^^^^^^^^ 18 | 19 | BayesOpt4ROS requires Python 3, which is the default version when you use ROS Noetic. 20 | If you are using ROS Melodic (or older) you first need to set up Python 3 for your workspace. 21 | 22 | Installation 23 | ^^^^^^^^^^^^ 24 | 25 | First, let's clone BayesOpt4ROS as a package into your workspace: 26 | 27 | .. code-block:: bash 28 | 29 | cd ${WORKSPACE} 30 | git clone https://github.com/lukasfro/bayesopt4ros.git src/bayesopt4ros 31 | 32 | Our package is written in Python and requires some 3rd parties libraries to work. 33 | You can easily install them via `pip `_: 34 | 35 | .. code-block:: bash 36 | 37 | python3 -m pip install -r src/bayesopt4ros/requirements.txt 38 | 39 | Now we should be ready to build the workspace: 40 | 41 | .. code-block:: bash 42 | 43 | catkin_make 44 | # Or 45 | catkin_make_isolated 46 | # Or 47 | catkin build 48 | 49 | # Don't forget to source the setup files 50 | source devel(_isolated)/setup.bash 51 | 52 | 53 | You should be good to go now. 54 | One way of quickly testing if the installation worked is by launching the server. 55 | 56 | .. code-block:: bash 57 | 58 | roslaunch bayesopt4ros bayesopt.launch 59 | 60 | You should see the output of the server similar to this: 61 | 62 | .. code-block:: bash 63 | 64 | [INFO] [1616593243.400756]: [BayesOptServer] Iteration 0: Ready to receive requests. 65 | 66 | .. note:: If roslaunch fails, make sure that the node script is executable. 67 | 68 | .. code-block:: bash 69 | 70 | chmod +x src/bayesopt4ros/nodes/bayesopt_node.py 71 | 72 | The server node is now ready to receive requests from a client (your node). 73 | Continue with the tutorial to see what callback methods you need to implement. 74 | 75 | Client implementation 76 | ^^^^^^^^^^^^^^^^^^^^^ 77 | 78 | The BayesOpt4ROS package uses the ActionServer/ActionClient communication pattern. 79 | On the `official ROS homepage `_ you can find tutorials for an exemplary implementation. 80 | Since BayesOpt4ROS already provides the server side, you just need to implement the client code. 81 | In the following, we show you what methods/callbacks you have to implement to communicate with the server (depending on the language of your choice). 82 | 83 | First, we need to instantiate the respective client node and tell it to listen to the `BayesOpt` server. 84 | 85 | C++: 86 | 87 | .. code-block:: c++ 88 | 89 | #include "actionlib/client/simple_action_client.h" 90 | #include "bayesopt4ros/BayesOptAction.h" 91 | 92 | Client client_node_("BayesOpt"); 93 | client_node_.waitForServer(); 94 | 95 | Python: 96 | 97 | .. code-block:: python3 98 | 99 | import actionlib 100 | from bayesopt4ros.msg import BayesOptAction, BayesOptGoal 101 | 102 | self.client = actionlib.SimpleActionClient("BayesOpt", BayesOptAction) 103 | self.client.wait_for_server() 104 | 105 | Querying the server to get new parameters is done by sending a 'goal'. 106 | Here is where you provide the server with your experiment's outcome via `y_new`, i.e., the function value of the objective that you want to optimize. 107 | 108 | C++: 109 | 110 | .. code-block:: c++ 111 | 112 | BayesOptGoal goal; 113 | goal.y_new = 42.0; 114 | client_node_.sendGoal(goal, boost::bind(&ExampleClient::bayesOptCallback, this, _1, _2)); 115 | 116 | Python: 117 | 118 | .. code-block:: python3 119 | 120 | goal = BayesOptGoal(y_new=y_new) 121 | self.client.send_goal(goal) 122 | 123 | Whenever the server is done computing a new set of parameters, the respective callback method is called. 124 | For the sake of this example, we just store the result in a class variable `x_new`. 125 | 126 | C++: 127 | 128 | .. code-block:: c++ 129 | 130 | void bayesOptCallback(const actionlib::SimpleClientGoalState& state, const BayesOptResultConstPtr& result) { 131 | x_new_ = result->x_new; 132 | } 133 | 134 | Python: 135 | 136 | .. code-block:: python3 137 | 138 | self.client.wait_for_result() 139 | result = self.client.get_result() 140 | x_new = result.x_new 141 | 142 | .. note:: The above Python example waits the server do be done until it continues running. This behaviour is not always desired. You can also implement an asynchronous pattern via callback functions in Python. The official ROS documentation does not provide such an example, but `this answer `_ has everything that you should need. 143 | 144 | That's already it! By repeatively querying the server via the pattern above, you will receive new parameters that will optimize your objective. 145 | Full examples of exemplary clients can be found `here for C++ `_ and `here for Python `_. 146 | 147 | 148 | .. _sec-getting-started-docker: 149 | 150 | Example workspace (Docker) 151 | -------------------------- 152 | 153 | In case you do not directly want to install BayesOpt4ROS into your own workspace, we provide `a repository `_ to test the package within a Docker container. 154 | If you do not have `Docker `_ installed, now would be a good time to do so. 155 | 156 | 157 | Set up Docker 158 | ^^^^^^^^^^^^^ 159 | 160 | First, clone the workspace repository: 161 | 162 | .. code-block:: bash 163 | 164 | git clone https://github.com/lukasfro/bayesopt4ros_workspace.git 165 | cd bayesopt4ros_workspace 166 | 167 | Next, let's create an image from the provided Dockerfile and run a container 168 | 169 | .. code-block:: bash 170 | 171 | # -t tags a docker image with a name, 'bayesopt4ros' in our case 172 | docker build -t bayesopt4ros . 173 | 174 | # -it runs the container in interactive mode 175 | # -v mounts our current directory to the workspace in the container 176 | docker run -it -v $(pwd):/root/ws/ bayesopt4ros 177 | 178 | .. note:: If you are working on a MacBook M1 with ARM chip, you need to adapt the Dockerfile to pull the right ROS image. 179 | Just have a look `here `_ and change the base image: 180 | 181 | .. code-block:: bash 182 | 183 | # For all machines except MacBooks with M1 CPU 184 | FROM osrf/ros:noetic-desktop-full 185 | 186 | # Use this image when your are using a MacBook with M1 CPU 187 | FROM arm64v8/ros:noetic 188 | 189 | Running test client 190 | ^^^^^^^^^^^^^^^^^^^ 191 | 192 | The following commands will only work within the Docker container. 193 | Let's build the workspace (choose any build system of your choice): 194 | 195 | .. code-block:: bash 196 | 197 | catkin_make_isolated 198 | source devel_isolated/setup.bash 199 | 200 | The easiest way to see the server in action is by executing one of the integration tests: 201 | 202 | .. code-block:: bash 203 | 204 | rostest bayesopt4ros test_client_python.test 205 | 206 | If you want to look at the results, we provide a small visualization script. 207 | You can run the following command outside of the running Docker container if you want to directly show the results. 208 | We also save an image to disk to the logging directory (this also works within the Docker container since we mounted your local workspace to the container workspace). 209 | 210 | .. code-block:: bash 211 | 212 | python3 visualize.py --log-dir logs/forrester_ucb 213 | 214 | The resulting image should look something like this 215 | 216 | .. image:: images/exemplary_results.png 217 | :width: 600 218 | :alt: Exemplary results 219 | 220 | 221 | 222 | -------------------------------------------------------------------------------- /src/bayesopt4ros/contextual_bayesopt.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import torch 4 | import yaml 5 | 6 | from torch import Tensor 7 | from typing import Tuple, List 8 | 9 | from botorch.models import SingleTaskGP 10 | from botorch.acquisition import AcquisitionFunction, FixedFeatureAcquisitionFunction 11 | from botorch.models.gpytorch import GPyTorchModel 12 | from botorch.models.transforms.input import Normalize 13 | from botorch.models.transforms.outcome import Standardize 14 | 15 | from gpytorch.kernels import MaternKernel, ScaleKernel 16 | from gpytorch.priors import GammaPrior 17 | 18 | from bayesopt4ros import BayesianOptimization 19 | from bayesopt4ros.data_handler import DataHandler 20 | from bayesopt4ros.util import PosteriorMean 21 | 22 | 23 | class ContextualBayesianOptimization(BayesianOptimization): 24 | """The contextual Bayesian optimization class. 25 | 26 | Implements the actual heavy lifting that is done under the hood of 27 | :class:`contextual_bayesopt_server.ContextualBayesOptServer`. 28 | 29 | See Also 30 | -------- 31 | :class:`bayesopt.BayesianOptimization` 32 | """ 33 | 34 | def __init__( 35 | self, 36 | input_dim: int, 37 | context_dim: int, 38 | max_iter: int, 39 | bounds: Tensor, 40 | acq_func: str = "UCB", 41 | n_init: int = 5, 42 | log_dir: str = None, 43 | load_dir: str = None, 44 | config: dict = None, 45 | maximize: bool = True, 46 | ) -> None: 47 | """The ContextualBayesianOptimization class initializer. 48 | 49 | .. note:: For a definition of the other arguments, see 50 | :class:`bayesopt.BayesianOptimization`. 51 | 52 | Parameters 53 | ---------- 54 | context_dim : int 55 | Number of context dimensions for the parameters. 56 | """ 57 | # Needs to be initialized before super() is called to check config in load_dir 58 | self.context_dim = context_dim 59 | self.context, self.prev_context = None, None 60 | self.joint_dim = input_dim + self.context_dim 61 | 62 | super().__init__( 63 | input_dim=input_dim, 64 | max_iter=max_iter, 65 | bounds=bounds, 66 | acq_func=acq_func, 67 | n_init=n_init, 68 | log_dir=log_dir, 69 | load_dir=load_dir, 70 | config=config, 71 | maximize=maximize, 72 | ) 73 | 74 | @classmethod 75 | def from_file(cls, config_file: str) -> ContextualBayesianOptimization: 76 | """Initialize a ContextualBayesianOptimization instance from a config file. 77 | 78 | Parameters 79 | ---------- 80 | config_file : str 81 | The config file (full path, relative or absolute). 82 | 83 | Returns 84 | ------- 85 | :class:`ContextualBayesianOptimization` 86 | An instance of the ContextualBayesianOptimization class. 87 | """ 88 | # Read config from file 89 | with open(config_file, "r") as f: 90 | config = yaml.load(f, Loader=yaml.FullLoader) 91 | 92 | # Bring bounds in correct format 93 | lb = torch.tensor(config["lower_bound"]) 94 | ub = torch.tensor(config["upper_bound"]) 95 | bounds = torch.stack((lb, ub)) 96 | 97 | # Construct class instance based on the config 98 | return cls( 99 | input_dim=config["input_dim"], 100 | context_dim=config["context_dim"], 101 | max_iter=config["max_iter"], 102 | bounds=bounds, 103 | acq_func=config["acq_func"], 104 | n_init=config["n_init"], 105 | log_dir=config.get("log_dir"), 106 | load_dir=config.get("load_dir"), 107 | maximize=config["maximize"], 108 | config=config, 109 | ) 110 | 111 | def get_optimal_parameters(self, context=None) -> Tuple[torch.Tensor, float]: 112 | """Get the optimal parameters for given context with corresponding value. 113 | 114 | .. note:: 'Optimal' referes to the optimum of the GP model. 115 | 116 | Parameters 117 | ---------- 118 | context : torch.Tensor, optional 119 | The context for which to get the optimal parameters. If none is 120 | none is provided, use the last observed context. 121 | 122 | Returns 123 | ------- 124 | torch.Tensor 125 | Location of the GP posterior mean's optimum for the given context. 126 | float 127 | Function value of the GP posterior mean's optium for the given context. 128 | 129 | See Also 130 | -------- 131 | get_best_observation 132 | """ 133 | return self._optimize_posterior_mean(context) 134 | 135 | def get_best_observation(self) -> Tuple[torch.Tensor, torch.Tensor, float]: 136 | """Get the best parameters, context and corresponding observed value. 137 | 138 | .. note:: 'Best' refers to the highest/lowest observed datum. 139 | 140 | Returns 141 | ------- 142 | torch.Tensor 143 | Location of the highest/lowest observed datum. 144 | torch.Tensor 145 | Context of the highest/lowest observed datum. 146 | float 147 | Function value of the highest/lowest observed datum. 148 | 149 | See Also 150 | -------- 151 | get_optimal_parameters 152 | """ 153 | x_best, c_best = torch.split( 154 | self.data_handler.x_best, [self.input_dim, self.context_dim] 155 | ) 156 | return x_best, c_best, self.data_handler.y_best 157 | 158 | @property 159 | def constant_config_parameters(self) -> List[str]: 160 | """These parameters need to be the same when loading previous runs. For 161 | all other settings, the user might have a reasonable explanation to 162 | change it inbetween experiments/runs. E.g., maximum number of iterations 163 | or bounds. 164 | 165 | See Also 166 | -------- 167 | _check_config 168 | """ 169 | return ["input_dim", "context_dim", "maximize"] 170 | 171 | def _update_model(self, goal): 172 | """Updates the GP with new data as well as the current context. Creates 173 | a model if none exists yet. 174 | 175 | Parameters 176 | ---------- 177 | goal : ContextualBayesOptAction 178 | The goal (context variable of the current goal is always pre-ceding 179 | the function value, i.e., the goal consists of [y_n, c_{n+1}]) sent 180 | from the client for the most recent experiment. 181 | """ 182 | if self.x_new is None and self.context is None: 183 | # The very first function value we obtain from the client is just to 184 | # trigger the server. At that point, there is no new input point, 185 | # hence, no need to need to update the model. However, the initial 186 | # context is already valid. 187 | self.context = torch.tensor(goal.c_new) 188 | self.prev_context = self.context 189 | return 190 | 191 | # Concatenate context and optimization variable 192 | x = torch.cat((self.x_new, self.context)) 193 | self.data_handler.add_xy(x=x, y=goal.y_new) 194 | self.prev_context = self.context 195 | self.context = torch.tensor(goal.c_new) 196 | 197 | # Note: We always create a GP model from scratch when receiving new data. 198 | # The reason is the following: if the 'set_train_data' method of the GP 199 | # is used instead, the normalization/standardization of the input/output 200 | # data is not updated in the GPyTorchModel. We also want at least 2 data 201 | # points such that the input normalization works properly. 202 | if self.data_handler.n_data >= 2: 203 | self.gp = self._initialize_model(self.data_handler) 204 | self._fit_model() 205 | 206 | def _initialize_model(self, data_handler: DataHandler) -> GPyTorchModel: 207 | """Creates a GP object from data. 208 | 209 | .. note:: Currently the kernel types are hard-coded. However, Matern is 210 | a good default choice. The joint kernel is just the multiplication 211 | of the parameter and context kernels. 212 | 213 | Parameters 214 | ---------- 215 | :class:`DataHandler` 216 | A data handler object containing the observations to create the model. 217 | 218 | Returns 219 | ------- 220 | :class:`GPyTorchModel` 221 | A GP object. 222 | """ 223 | # Kernel for optimization variables 224 | ad0 = tuple(range(self.input_dim)) 225 | k0 = MaternKernel(active_dims=ad0, lengthscale_prior=GammaPrior(3.0, 6.0)) 226 | 227 | # Kernel for context variables 228 | ad1 = tuple(range(self.input_dim, self.input_dim + self.context_dim)) 229 | k1 = MaternKernel(active_dims=ad1, lengthscale_prior=GammaPrior(3.0, 6.0)) 230 | 231 | # Joint kernel is constructed via multiplication 232 | covar_module = ScaleKernel(k0 * k1, outputscale_prior=GammaPrior(2.0, 0.15)) 233 | 234 | # For contextual BO, we do not want to specify the bounds for the context 235 | # variables (who knows what they might be...). We therefore use the neat 236 | # feature of BoTorch to infer the normalization bounds from data. However, 237 | # this does not work is only a single data point is given. 238 | input_transform = ( 239 | Normalize(d=self.joint_dim) if len(self.data_handler) > 1 else None 240 | ) 241 | x, y = data_handler.get_xy() 242 | gp = SingleTaskGP( 243 | train_X=x, 244 | train_Y=y, 245 | outcome_transform=Standardize(m=1), 246 | input_transform=input_transform, 247 | covar_module=covar_module, 248 | ) 249 | return gp 250 | 251 | def _initialize_acqf(self) -> FixedFeatureAcquisitionFunction: 252 | """Initialize the acquisition function of choice and wrap it with the 253 | FixedFeatureAcquisitionFunction given the current context. 254 | 255 | Returns 256 | ------- 257 | FixedFeatureAcquisitionFunction 258 | An acquisition function of choice with fixed features. 259 | """ 260 | acq_func = super()._initialize_acqf() 261 | columns = [i + self.input_dim for i in range(self.context_dim)] 262 | values = self.context.tolist() 263 | acq_func_ff = FixedFeatureAcquisitionFunction( 264 | acq_func, d=self.joint_dim, columns=columns, values=values 265 | ) 266 | return acq_func_ff 267 | 268 | def _optimize_acqf( 269 | self, acq_func: AcquisitionFunction, visualize: bool = False 270 | ) -> Tuple[Tensor, float]: 271 | """Optimizes the acquisition function with the context variable fixed. 272 | 273 | Note: The debug visualization is turned off for contextual setting. 274 | 275 | Parameters 276 | ---------- 277 | acq_func : AcquisitionFunction 278 | The acquisition function to optimize. 279 | visualize : bool 280 | Flag if debug visualization should be turned on. 281 | 282 | Returns 283 | ------- 284 | x_opt : torch.Tensor 285 | Location of the acquisition function's optimum. 286 | f_opt : float 287 | Value of the acquisition function's optimum. 288 | """ 289 | x_opt, f_opt = super()._optimize_acqf(acq_func, visualize=False) 290 | if visualize: 291 | pass 292 | return x_opt, f_opt 293 | 294 | def _optimize_posterior_mean(self, context=None) -> Tuple[Tensor, float]: 295 | """Optimizes the posterior mean function with a fixed context variable. 296 | 297 | Instead of implementing this functionality from scratch, simply use the 298 | exploitative acquisition function with BoTorch's optimization. 299 | 300 | Parameters 301 | ---------- 302 | context : torch.Tensor, optional 303 | The context for which to compute the mean's optimum. If none is 304 | specified, use the last one that was received. 305 | 306 | Returns 307 | ------- 308 | x_opt : torch.Tensor 309 | Location of the posterior mean function's optimum. 310 | f_opt : float 311 | Value of the posterior mean function's optimum. 312 | """ 313 | context = context or self.prev_context 314 | if not isinstance(context, torch.Tensor): 315 | context = torch.tensor(context) 316 | 317 | columns = [i + self.input_dim for i in range(self.context_dim)] 318 | values = context.tolist() 319 | 320 | pm = PosteriorMean(model=self.gp, maximize=self.maximize) 321 | pm_ff = FixedFeatureAcquisitionFunction(pm, self.joint_dim, columns, values) 322 | 323 | x_opt, f_opt = super()._optimize_acqf(pm_ff, visualize=False) 324 | f_opt = f_opt if self.maximize else -1 * f_opt 325 | return x_opt, f_opt 326 | 327 | def _check_data_vicinity(self, x1, x2): 328 | """Returns true if `x1` is close to any point in `x2`. 329 | 330 | .. note:: We are following Binois and Picheny (2019) and check if the 331 | proposed point is too close to any existing data points to avoid 332 | numerical issues. In that case, choose a random point instead. 333 | https://www.jstatsoft.org/article/view/v089i08 334 | 335 | .. note:: `x1` is considered without context whereas `x2` contains the 336 | context. The reasons for that is to have a consistent interface 337 | with the standard BO implementation. 338 | 339 | Parameters 340 | ---------- 341 | x1 : torch.Tensor 342 | A single data point. 343 | x2 : torch.Tensor 344 | Multiple data points. 345 | 346 | Returns 347 | ------- 348 | bool 349 | Returns `True` if `x1` is close to any point in `x2` else returns `False` 350 | """ 351 | xc1 = torch.cat((x1, self.context)) 352 | return super()._check_data_vicinity(xc1, x2) 353 | -------------------------------------------------------------------------------- /src/bayesopt4ros/bayesopt.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import rospy 5 | import torch 6 | import yaml 7 | 8 | from torch import Tensor 9 | from typing import List, Tuple, Union 10 | 11 | from botorch.acquisition import ( 12 | AcquisitionFunction, 13 | UpperConfidenceBound, 14 | ExpectedImprovement, 15 | ) 16 | 17 | from botorch.fit import fit_gpytorch_model 18 | from botorch.models import SingleTaskGP 19 | from botorch.models.gpytorch import GPyTorchModel 20 | from botorch.models.transforms.input import Normalize 21 | from botorch.models.transforms.outcome import Standardize 22 | from botorch.optim import optimize_acqf as optimize_acqf_botorch 23 | from botorch.optim.fit import fit_gpytorch_torch 24 | 25 | from gpytorch.mlls import ExactMarginalLogLikelihood 26 | 27 | from bayesopt4ros import util 28 | from bayesopt4ros.data_handler import DataHandler 29 | from bayesopt4ros.msg import BayesOptAction # type: ignore 30 | from bayesopt4ros.util import PosteriorMean 31 | 32 | 33 | class BayesianOptimization(object): 34 | """The Bayesian optimization class. 35 | 36 | Implements the actual heavy lifting that is done under the hood of 37 | :class:`bayesopt_server.BayesOptServer`. 38 | 39 | """ 40 | 41 | def __init__( 42 | self, 43 | input_dim: int, 44 | max_iter: int, 45 | bounds: Tensor, 46 | acq_func: str = "UCB", 47 | n_init: int = 5, 48 | log_dir: str = None, 49 | load_dir: str = None, 50 | config: dict = None, 51 | maximize: bool = True, 52 | debug_visualization: bool = False, 53 | ) -> None: 54 | """The BayesianOptimization class initializer. 55 | 56 | .. note:: If a `log_dir` is specified, three different files will be 57 | created: 1) evaluations file, 2) model file, 3) config file. As the 58 | names suggest, these store the evaluated points, the final GP model 59 | as well as the configuration, respectively. 60 | 61 | Parameters 62 | ---------- 63 | input_dim : int 64 | Number of input dimensions for the parameters. 65 | max_iter : int 66 | Maximum number of iterations. 67 | bounds : torch.Tensor 68 | A [2, input_dim] shaped tensor specifying the optimization domain. 69 | acq_func : str 70 | The acquisition function specifier. 71 | n_init : int 72 | Number of point for initial design, i.e. Sobol. 73 | log_dir : str 74 | Directory to which the log files are stored. 75 | load_dir : str or list of str 76 | Directory/directories from which initial data points are loaded. 77 | config : dict 78 | The configuration dictionary for the experiment. 79 | maximize : bool 80 | If True, consider the problem a maximization problem. 81 | debug_visualization : bool 82 | If True, the optimization of the acquisition function is visualized. 83 | """ 84 | self.input_dim = input_dim 85 | self.max_iter = max_iter 86 | self.bounds = bounds 87 | self.acq_func = acq_func 88 | self.n_init = n_init 89 | self.x_init = self._initial_design(n_init) 90 | self.x_new = None 91 | self.config = config 92 | self.maximize = maximize 93 | self.debug_visualization = debug_visualization 94 | self.data_handler = DataHandler(maximize=self.maximize) 95 | self.gp = None # GP is initialized when first data arrives 96 | self.x_opt = torch.empty(0, input_dim) 97 | self.y_opt = torch.empty(0, 1) 98 | 99 | if load_dir is not None: 100 | self.data_handler, self.gp = self._load_prev_bayesopt(load_dir) 101 | 102 | if log_dir is not None: 103 | self.log_dir = util.create_log_dir(log_dir) 104 | 105 | assert bounds.shape[1] == self.input_dim 106 | 107 | @classmethod 108 | def from_file(cls, config_file: str) -> BayesianOptimization: 109 | """Initialize a BayesianOptimization instance from a config file. 110 | 111 | Parameters 112 | ---------- 113 | config_file : str 114 | The config file (full path, relative or absolute). 115 | 116 | Returns 117 | ------- 118 | :class:`BayesianOptimization` 119 | An instance of the BayesianOptimization class. 120 | """ 121 | # Read config from file 122 | with open(config_file, "r") as f: 123 | config = yaml.load(f, Loader=yaml.FullLoader) 124 | 125 | # Bring bounds in correct format 126 | lb = torch.tensor(config["lower_bound"]) 127 | ub = torch.tensor(config["upper_bound"]) 128 | bounds = torch.stack((lb, ub)) 129 | 130 | # Construct class instance based on the config 131 | return cls( 132 | input_dim=config["input_dim"], 133 | max_iter=config["max_iter"], 134 | bounds=bounds, 135 | acq_func=config["acq_func"], 136 | n_init=config["n_init"], 137 | log_dir=config["log_dir"], 138 | load_dir=config.get("load_dir"), 139 | maximize=config["maximize"], 140 | config=config, 141 | ) 142 | 143 | def next(self, goal: BayesOptAction) -> Tensor: 144 | """Compute new parameters to perform an experiment with. 145 | 146 | The functionality of this method can generally be split into three steps: 147 | 148 | 1) Update the model with the new data. 149 | 2) Retrieve a new point as response of the server. 150 | 3) Save current state to file. 151 | 152 | Parameters 153 | ---------- 154 | goal : BayesOptAction 155 | The goal sent from the client for the most recent experiment. 156 | 157 | Returns 158 | ------- 159 | torch.Tensor 160 | The new parameters as an array. 161 | """ 162 | # 1) Update the model with the new data 163 | self._update_model(goal) 164 | 165 | # 2) Retrieve a new point as response of the server 166 | self.x_new = self._get_next_x() 167 | 168 | # 3) Save current state to file 169 | self._log_results() 170 | 171 | return self.x_new 172 | 173 | def update_last_goal(self, goal: BayesOptAction) -> None: 174 | """Updates the GP model with the last function value obtained. 175 | 176 | .. note:: This function is only called once from the server, right before 177 | shutting down the node. However, we still want to update the GP model 178 | with the latest data. 179 | 180 | Parameters 181 | ---------- 182 | goal : BayesOptAction 183 | The goal sent from the client for the last recent experiment. 184 | """ 185 | self._update_model(goal) 186 | self._log_results() 187 | 188 | def get_optimal_parameters(self) -> Tuple[torch.Tensor, float]: 189 | """Get the optimal parameters with corresponding expected value. 190 | 191 | .. note:: 'Optimal' referes to the optimum of the GP model. 192 | 193 | Returns 194 | ------- 195 | torch.Tensor 196 | Location of the GP posterior mean's optimum. 197 | float 198 | Function value of the GP posterior mean's optium. 199 | 200 | See Also 201 | -------- 202 | get_best_observation 203 | """ 204 | return self._optimize_posterior_mean() 205 | 206 | def get_best_observation(self) -> Tuple[torch.Tensor, float]: 207 | """Get the best parameters and corresponding observed value. 208 | 209 | .. note:: 'Best' refers to the highest/lowest observed datum. 210 | 211 | Returns 212 | ------- 213 | torch.Tensor 214 | Location of the highest/lowest observed datum. 215 | float 216 | Function value of the highest/lowest observed datum. 217 | 218 | See Also 219 | -------- 220 | get_optimal_parameters 221 | """ 222 | return self.data_handler.x_best, self.data_handler.y_best 223 | 224 | @property 225 | def constant_config_parameters(self) -> List[str]: 226 | """These parameters need to be the same when loading previous runs. For 227 | all other settings, the user might have a reasonable explanation to 228 | change it inbetween experiments/runs. E.g., maximum number of iterations 229 | or bounds. 230 | 231 | See Also 232 | -------- 233 | _check_config 234 | """ 235 | return ["input_dim", "maximize"] 236 | 237 | @property 238 | def n_data(self) -> int: 239 | """Property for conveniently accessing number of data points.""" 240 | return self.data_handler.n_data 241 | 242 | def _get_next_x(self) -> Tensor: 243 | """Computes the next point to evaluate. 244 | 245 | Returns 246 | ------- 247 | torch.Tensor 248 | The next point to evaluate. 249 | """ 250 | if self.n_data < self.n_init: # We are in the initialization phase 251 | x_new = self.x_init[self.n_data] 252 | else: # Actually optimizing the acquisition function for new points 253 | x_new = self._optimize_acqf( 254 | self._initialize_acqf(), visualize=self.debug_visualization 255 | )[0] 256 | 257 | # To avoid numerical issues and encourage exploration 258 | if self._check_data_vicinity(x_new, self.data_handler.get_xy()[0]): 259 | rospy.logwarn("[BayesOpt] x_new is too close to existing data.") 260 | lb, ub = self.bounds[0], self.bounds[1] 261 | x_rand = lb + (ub - lb) * torch.rand((self.input_dim,)) 262 | x_new = x_rand 263 | 264 | return x_new 265 | 266 | def _check_config(self, load_dirs: List[str]): 267 | """Make sure that all relevant parameters in the configs match. 268 | 269 | Parameters 270 | ---------- 271 | load_dirs : str or List[str] 272 | The directories to the previous experiments, which are loaded. 273 | """ 274 | load_dirs = [load_dirs] if isinstance(load_dirs, str) else load_dirs 275 | for load_dir in load_dirs: 276 | with open(os.path.join(load_dir, "config.yaml")) as f: 277 | load_config = yaml.load(f, Loader=yaml.FullLoader) 278 | 279 | for p in self.constant_config_parameters: 280 | try: 281 | assert load_config[p] == self.__getattribute__(p) 282 | except AssertionError: 283 | rospy.logerr(f"Your configuration does not match with {load_dir}") 284 | 285 | def _load_prev_bayesopt( 286 | self, load_dirs: Union[str, List[str]] 287 | ) -> Tuple[DataHandler, GPyTorchModel]: 288 | """Load data from previous BO experiments. 289 | 290 | Parameters 291 | ---------- 292 | load_dirs : str or List[str] 293 | The directories to the previous experiments, which are loaded. 294 | 295 | Returns 296 | ------- 297 | :class:`DataHandler` 298 | An data handler object with filled with observations from previous 299 | experiments. 300 | :class:`GPyTorchModel` 301 | A GP object that has been trained on the data from previous experiments. 302 | """ 303 | # We can load multiple previous runs 304 | load_dirs = [load_dirs] if isinstance(load_dirs, str) else load_dirs 305 | 306 | # Configurations need to be compatible with the current one 307 | self._check_config(load_dirs) 308 | 309 | # Create model with the previous runs' data 310 | data_files = [ 311 | os.path.join(load_dir, "evaluations.yaml") for load_dir in load_dirs 312 | ] 313 | self.data_handler = DataHandler.from_file(data_files) 314 | self.data_handler.maximize = self.maximize 315 | self.gp = self._initialize_model(self.data_handler) 316 | self._fit_model() 317 | 318 | return self.data_handler, self.gp 319 | 320 | def _update_model(self, goal) -> None: 321 | """Updates the GP with new data. Creates a model if none exists yet. 322 | 323 | Parameters 324 | ---------- 325 | goal : BayesOptAction 326 | The goal sent from the client for the most recent experiment. 327 | """ 328 | if self.x_new is None: 329 | # The very first function value we obtain from the client is just to 330 | # trigger the server. At that point, there is no new input point, 331 | # hence, no need to need to update the model. 332 | return 333 | 334 | # Note: We always create a GP model from scratch when receiving new data. 335 | # The reason is the following: if the 'set_train_data' method of the GP 336 | # is used instead, the normalization/standardization of the input/output 337 | # data is not updated in the GPyTorchModel. We also want at least 2 data 338 | # points such that the input normalization works properly. 339 | self.data_handler.add_xy(x=self.x_new, y=goal.y_new) 340 | self.gp = self._initialize_model(self.data_handler) 341 | self._fit_model() 342 | 343 | def _initialize_model(self, data_handler: DataHandler) -> GPyTorchModel: 344 | """Creates a GP object from data. 345 | 346 | .. note:: Currently the kernel types are hard-coded. However, Matern is 347 | a good default choice. 348 | 349 | Parameters 350 | ---------- 351 | :class:`DataHandler` 352 | A data handler object containing the observations to create the model. 353 | 354 | Returns 355 | ------- 356 | :class:`GPyTorchModel` 357 | A GP object. 358 | """ 359 | x, y = data_handler.get_xy() 360 | gp = SingleTaskGP( 361 | train_X=x, 362 | train_Y=y, 363 | outcome_transform=Standardize(m=1), 364 | input_transform=Normalize(d=self.input_dim, bounds=self.bounds), 365 | ) 366 | return gp 367 | 368 | def _fit_model(self) -> None: 369 | """Performs inference and fits the GP to the data.""" 370 | # Scipy optimizers is faster and more accurate but tends to be numerically 371 | # less table for single precision. To avoid error checking, we use the 372 | # stochastic optimizer. 373 | mll = ExactMarginalLogLikelihood(self.gp.likelihood, self.gp) 374 | fit_gpytorch_model(mll, optimizer=fit_gpytorch_torch, options={"disp": False}) 375 | 376 | def _initialize_acqf(self) -> AcquisitionFunction: 377 | """Initialize the acquisition function of choice. 378 | 379 | Returns 380 | ------- 381 | :class:`AcquisitionFunction` 382 | An acquisition function based on BoTorch's base class. 383 | """ 384 | if self.acq_func.upper() == "UCB": 385 | acq_func = UpperConfidenceBound( 386 | model=self.gp, beta=4.0, maximize=self.maximize 387 | ) 388 | elif self.acq_func.upper() == "EI": 389 | best_f = self.data_handler.y_best # note that EI assumes noiseless 390 | acq_func = ExpectedImprovement( 391 | model=self.gp, best_f=best_f, maximize=self.maximize 392 | ) 393 | elif self.acq_func.upper() == "NEI": 394 | raise NotImplementedError( 395 | "Not implemented yet. Always leads to numerical issues" 396 | ) 397 | else: 398 | raise NotImplementedError( 399 | f"{self.acq_func} is not a valid acquisition function" 400 | ) 401 | return acq_func 402 | 403 | def _optimize_acqf( 404 | self, acq_func: AcquisitionFunction, visualize: bool = False 405 | ) -> Tuple[Tensor, float]: 406 | """Optimizes the acquisition function. 407 | 408 | Parameters 409 | ---------- 410 | acq_func : :class:`AcquisitionFunction` 411 | The acquisition function to optimize. 412 | visualize : bool 413 | Flag if debug visualization should be turned on. 414 | 415 | Returns 416 | ------- 417 | x_opt : torch.Tensor 418 | Location of the acquisition function's optimum. 419 | f_opt : float 420 | Value of the acquisition function's optimum. 421 | """ 422 | x_opt, f_opt = optimize_acqf_botorch( 423 | acq_func, 424 | self.bounds, 425 | q=1, 426 | num_restarts=10, 427 | raw_samples=2000, 428 | sequential=True, 429 | ) 430 | 431 | if visualize: 432 | self._debug_acqf_visualize(acq_func, x_opt, f_opt) 433 | 434 | x_opt = x_opt.squeeze(0) # gets rid of superfluous dimension due to q=1 435 | return x_opt, f_opt 436 | 437 | def _optimize_posterior_mean(self) -> Tuple[Tensor, float]: 438 | """Optimizes the posterior mean function. 439 | 440 | Instead of implementing this functionality from scratch, simply use the 441 | exploitative acquisition function with BoTorch's optimization. 442 | 443 | Returns 444 | ------- 445 | x_opt : torch.Tensor 446 | Location of the posterior mean function's optimum. 447 | f_opt : float 448 | Value of the posterior mean function's optimum. 449 | """ 450 | posterior_mean = PosteriorMean(model=self.gp, maximize=self.maximize) 451 | x_opt, f_opt = self._optimize_acqf(posterior_mean) 452 | f_opt = f_opt if self.maximize else -1 * f_opt 453 | return x_opt, f_opt 454 | 455 | def _initial_design(self, n_init: int) -> Tensor: 456 | """Create initial data points from a Sobol sequence. 457 | 458 | Parameters 459 | ---------- 460 | n_init : int 461 | Number of initial points. 462 | 463 | Returns 464 | ------- 465 | torch.Tensor 466 | Array containing the initial points. 467 | """ 468 | sobol_eng = torch.quasirandom.SobolEngine(dimension=self.input_dim) 469 | sobol_eng.fast_forward(n=1) # first point is origin, boring... 470 | x0_init = sobol_eng.draw(n_init) # points are in [0, 1]^d 471 | return self.bounds[0] + (self.bounds[1] - self.bounds[0]) * x0_init 472 | 473 | def _check_data_vicinity(self, x1, x2): 474 | """Returns true if `x1` is close to any point in `x2`. 475 | 476 | .. note:: We are following Binois and Picheny (2019) and check if the 477 | proposed point is too close to any existing data points to avoid 478 | numerical issues. In that case, choose a random point instead. 479 | https://www.jstatsoft.org/article/view/v089i08 480 | 481 | Parameters 482 | ---------- 483 | x1 : torch.Tensor 484 | A single data point. 485 | x2 : torch.Tensor 486 | Multiple data points. 487 | 488 | Returns 489 | ------- 490 | bool 491 | Returns `True` if `x1` is close to any point in `x2` else returns `False` 492 | """ 493 | x1 = torch.atleast_2d(x1) 494 | assert x1.shape[0] == 1 495 | X = torch.cat((x2, x1)) 496 | c = self.gp.posterior(X).mvn.covariance_matrix 497 | cis = c[-1, :-1] 498 | cii = c.diag()[:-1] 499 | css = c.diag()[-1] 500 | kss = self.gp.covar_module.outputscale 501 | d = torch.min((cii + css - 2 * cis) / kss) 502 | return d < 1e-5 503 | 504 | def _log_results(self) -> None: 505 | """Log evaluations and GP model to file. 506 | 507 | .. note:: We do this at each iteration and overwrite the existing file in 508 | case something goes wrong with either the optimization itself or on 509 | the client side. We do not want to loose any valuable experimental data. 510 | """ 511 | if not self.log_dir or self.gp is None: 512 | return 513 | 514 | # Saving GP model to file 515 | self.model_file = os.path.join(self.log_dir, "model_state.pth") 516 | torch.save(self.gp.state_dict(), self.model_file) 517 | 518 | # Save config to file 519 | self.config_file = os.path.join(self.log_dir, "config.yaml") 520 | yaml.dump(self.config, open(self.config_file, "w")) 521 | 522 | # Compute rolling best input/ouput pair 523 | x_best = self.data_handler.x_best_accumulate 524 | y_best = self.data_handler.y_best_accumulate 525 | 526 | # Update optimal parameters 527 | xn_opt, yn_opt = self.get_optimal_parameters() 528 | self.x_opt = torch.cat((self.x_opt, torch.atleast_2d(xn_opt))) 529 | self.y_opt = torch.cat((self.y_opt, torch.tensor([[yn_opt]]))) 530 | 531 | # Store all and optimal evaluation inputs/outputs to file 532 | data = self.data_handler.get_xy(as_dict=True) 533 | data.update({"x_best": x_best, "y_best": y_best}) 534 | data.update({"x_opt": self.x_opt, "y_opt": self.y_opt}) 535 | data = {k: v.tolist() for k, v in data.items()} 536 | self.evaluations_file = os.path.join(self.log_dir, "evaluations.yaml") 537 | yaml.dump(data, open(self.evaluations_file, "w"), indent=2) 538 | 539 | def _debug_acqf_visualize(self, acq_func, x_opt, f_opt): 540 | """Visualize the acquisition function for debugging purposes.""" 541 | import matplotlib.pyplot as plt 542 | 543 | if self.input_dim not in [1, 2]: 544 | return 545 | elif self.input_dim == 1: 546 | # The plotting ranges 547 | lb, ub = self.bounds[0], self.bounds[1] 548 | xs = torch.linspace(lb.item(), ub.item(), 500).unsqueeze(-1) 549 | 550 | # Evaluate GP and acquisition function 551 | posterior = self.gp.posterior(xs, observation_noise=False) 552 | mean = posterior.mean.squeeze().detach() 553 | std = posterior.variance.sqrt().squeeze().detach() 554 | acqf = acq_func(xs.unsqueeze(1).unsqueeze(1)).squeeze().detach() 555 | x_eval, y_eval = self.data_handler.get_xy() 556 | 557 | # Create plot 558 | _, axes = plt.subplots(nrows=2, ncols=1) 559 | axes[0].plot(xs, mean, label="GP mean") 560 | axes[0].fill_between( 561 | xs.squeeze(), mean + 2 * std, mean - 2 * std, alpha=0.3 562 | ) 563 | axes[0].plot(x_eval, y_eval, "ko") 564 | axes[0].grid() 565 | 566 | axes[1].plot(xs, acqf) 567 | axes[1].plot(x_opt, f_opt, "C3x") 568 | axes[1].grid() 569 | elif self.input_dim == 2: 570 | # The plotting ranges 571 | lb, ub = self.bounds[0], self.bounds[1] 572 | x1 = torch.linspace(lb[0], ub[0], 100) 573 | x2 = torch.linspace(lb[1], ub[1], 100) 574 | x1, x2 = torch.meshgrid(x1, x2) 575 | xs = torch.stack((x1.flatten(), x2.flatten())).T 576 | 577 | # Evaluate GP and acquisition function 578 | gpm = self.gp.posterior(xs).mean.squeeze().detach().view(100, 100) 579 | acqf = acq_func(xs.unsqueeze(1)).squeeze().detach().view(100, 100) 580 | x_eval = self.data_handler.get_xy()[0] 581 | 582 | # Create plot 583 | _, axes = plt.subplots(nrows=1, ncols=2) 584 | axes[0].contourf(x1, x2, gpm, levels=50) 585 | axes[0].plot(x_eval[:, 0], x_eval[:, 1], "ko") 586 | axes[0].plot(x_opt[0, 0], x_opt[0, 1], "C3x") 587 | axes[0].axis("equal") 588 | c = axes[1].contourf(x1, x2, acqf, levels=50) 589 | axes[1].plot(x_opt[0, 0], x_opt[0, 1], "C3x") 590 | axes[1].axis("equal") 591 | 592 | plt.colorbar(c) 593 | 594 | plt.tight_layout() 595 | file_name = os.path.join(self.log_dir, f"acqf_visualize_{x_eval.shape[0]}.pdf") 596 | rospy.logdebug(f"Saving debug visualization to: {file_name}") 597 | plt.savefig(file_name, format="pdf") 598 | plt.close() 599 | --------------------------------------------------------------------------------