`_ 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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
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 |
--------------------------------------------------------------------------------