From 19c32babaa2f90a676ac62bdc814ef07350ff40d Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Wed, 14 Aug 2024 13:56:12 -0500 Subject: [PATCH 01/36] Temporarily avoid new version of FLAML (#840) See Also: #839 Also fixes `make dist-test` rules to workaround recently broken `pip` version interpretation when using symlinked whl files. --- Makefile | 6 +++--- mlos_core/setup.py | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 2c8fc36a870..8eb06a70f83 100644 --- a/Makefile +++ b/Makefile @@ -559,13 +559,13 @@ build/dist-test-env.$(PYTHON_VERSION).build-stamp: mlos_viz/dist/tmp/mlos_viz-la # Install some additional dependencies necessary for clean building some of the wheels. conda install -y ${CONDA_INFO_LEVEL} -n mlos-dist-test-$(PYTHON_VERSION) swig libpq # Test a clean install of the mlos_core wheel. - conda run -n mlos-dist-test-$(PYTHON_VERSION) pip install "mlos_core/dist/tmp/mlos_core-latest-py3-none-any.whl[full-tests]" + conda run -n mlos-dist-test-$(PYTHON_VERSION) pip install "`readlink -f mlos_core/dist/tmp/mlos_core-latest-py3-none-any.whl`[full-tests]" # Test a clean install of the mlos_bench wheel. - conda run -n mlos-dist-test-$(PYTHON_VERSION) pip install "mlos_bench/dist/tmp/mlos_bench-latest-py3-none-any.whl[full-tests]" + conda run -n mlos-dist-test-$(PYTHON_VERSION) pip install "`readlink -f mlos_bench/dist/tmp/mlos_bench-latest-py3-none-any.whl`[full-tests]" # Test that the config dir for mlos_bench got distributed. test -e `conda env list | grep "mlos-dist-test-$(PYTHON_VERSION) " | awk '{ print $$2 }'`/lib/python$(PYTHON_VERS_REQ)/site-packages/mlos_bench/config/README.md # Test a clean install of the mlos_viz wheel. - conda run -n mlos-dist-test-$(PYTHON_VERSION) pip install "mlos_viz/dist/tmp/mlos_viz-latest-py3-none-any.whl[full-tests]" + conda run -n mlos-dist-test-$(PYTHON_VERSION) pip install "`readlink -f mlos_viz/dist/tmp/mlos_viz-latest-py3-none-any.whl`[full-tests]" touch $@ .PHONY: dist-test diff --git a/mlos_core/setup.py b/mlos_core/setup.py index 61f3b8945f5..34a0032631a 100644 --- a/mlos_core/setup.py +++ b/mlos_core/setup.py @@ -68,7 +68,10 @@ def _get_long_desc_from_readme(base_url: str) -> dict: extra_requires: Dict[str, List[str]] = { # pylint: disable=consider-using-namedtuple-or-dataclass - "flaml": ["flaml[blendsearch]"], + "flaml": [ + "flaml<2.2.0", # FIXME: temporarily avoid changes in new FLAML package (#839). + "flaml[blendsearch]", + ], # NOTE: Major refactoring on SMAC and ConfigSpace v1.0 starting from v2.2 "smac": ["smac>=2.2.0"], } From f3eb624a5b3f11def89a4ff329111c0dc36e52f9 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Wed, 14 Aug 2024 14:14:55 -0500 Subject: [PATCH 02/36] Fixup to release version tagged devcontainer publishing logic (#841) Use a tagged release first, and a latest release secondarily. --- .github/workflows/devcontainer.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index d2a24f6722b..c07a902fc6c 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -232,10 +232,10 @@ jobs: run: | set -x image_tag='' - if [ "${{ github.ref }}" == 'refs/heads/main' ]; then - image_tag='latest' - elif [ "${{ github.ref_type }}" == 'tag' ]; then + if [ "${{ github.ref_type }}" == 'tag' ]; then image_tag="${{ github.ref_name }}" + elif [ "${{ github.ref }}" == 'refs/heads/main' ]; then + image_tag='latest' fi if [ -z "$image_tag" ]; then echo "ERROR: Unhandled event condition or ref: event=${{ github.event}}, ref=${{ github.ref }}, ref_type=${{ github.ref_type }}" From 2e4cfa26a0768cc15bfce47aaad379f7bb840695 Mon Sep 17 00:00:00 2001 From: Sergiy Matusevych Date: Fri, 16 Aug 2024 07:59:26 -0700 Subject: [PATCH 03/36] Use number of bins instead of quantization interval in mlos_bench tunables (#835) Closes #803 > Future PR will rename the config schema in order to reduce confusion on the change in semantics, but also keep this PR smaller. --------- Co-authored-by: Brian Kroth Co-authored-by: Brian Kroth --- .../tunables/tunable-params-schema.json | 11 ++- .../optimizers/convert_configspace.py | 56 +++++++++++----- .../optimizers/grid_search_optimizer.py | 6 +- ...le-params-int-bad-float-quantization.jsonc | 2 +- .../good/full/full-tunable-params-test.jsonc | 6 +- .../optimizers/grid_search_optimizer_test.py | 7 +- .../tests/tunable_groups_fixtures.py | 1 + .../tunables/test_tunables_size_props.py | 31 ++++++--- .../tests/tunables/tunable_definition_test.py | 6 +- .../tunable_to_configspace_quant_test.py | 67 +++++++++++++++++++ .../tunables/tunable_to_configspace_test.py | 7 +- mlos_bench/mlos_bench/tunables/tunable.py | 58 +++++++--------- 12 files changed, 179 insertions(+), 79 deletions(-) create mode 100644 mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_quant_test.py diff --git a/mlos_bench/mlos_bench/config/schemas/tunables/tunable-params-schema.json b/mlos_bench/mlos_bench/config/schemas/tunables/tunable-params-schema.json index 2380ad62fef..fd6c61b91d4 100644 --- a/mlos_bench/mlos_bench/config/schemas/tunables/tunable-params-schema.json +++ b/mlos_bench/mlos_bench/config/schemas/tunables/tunable-params-schema.json @@ -125,7 +125,8 @@ }, "quantization": { "description": "The number of buckets to quantize the range into.", - "$comment": "type left unspecified here" + "type": "integer", + "exclusiveMinimum": 1 }, "log_scale": { "description": "Whether to use log instead of linear scale for the range search.", @@ -217,9 +218,7 @@ "$ref": "#/$defs/tunable_param_distribution" }, "quantization": { - "$ref": "#/$defs/quantization", - "type": "integer", - "exclusiveMinimum": 1 + "$ref": "#/$defs/quantization" }, "log": { "$ref": "#/$defs/log_scale" @@ -267,9 +266,7 @@ "$ref": "#/$defs/tunable_param_distribution" }, "quantization": { - "$ref": "#/$defs/quantization", - "type": "number", - "exclusiveMinimum": 0 + "$ref": "#/$defs/quantization" }, "log": { "$ref": "#/$defs/log_scale" diff --git a/mlos_bench/mlos_bench/optimizers/convert_configspace.py b/mlos_bench/mlos_bench/optimizers/convert_configspace.py index 6244bba7b39..ad7968cf69a 100644 --- a/mlos_bench/mlos_bench/optimizers/convert_configspace.py +++ b/mlos_bench/mlos_bench/optimizers/convert_configspace.py @@ -11,8 +11,6 @@ from ConfigSpace import ( Beta, - BetaFloatHyperparameter, - BetaIntegerHyperparameter, CategoricalHyperparameter, Configuration, ConfigurationSpace, @@ -20,12 +18,10 @@ Float, Integer, Normal, - NormalFloatHyperparameter, - NormalIntegerHyperparameter, Uniform, - UniformFloatHyperparameter, - UniformIntegerHyperparameter, ) +from ConfigSpace.functional import quantize +from ConfigSpace.hyperparameters import NumericalHyperparameter from ConfigSpace.types import NotSet from mlos_bench.tunables.tunable import Tunable, TunableValue @@ -53,6 +49,37 @@ def _normalize_weights(weights: List[float]) -> List[float]: return [w / total for w in weights] +def _monkey_patch_quantization(hp: NumericalHyperparameter, quantization_bins: int) -> None: + """ + Monkey-patch quantization into the Hyperparameter. + + Parameters + ---------- + hp : NumericalHyperparameter + ConfigSpace hyperparameter to patch. + quantization_bins : int + Number of bins to quantize the hyperparameter into. + """ + if quantization_bins <= 1: + raise ValueError(f"{quantization_bins=} :: must be greater than 1.") + + # Temporary workaround to dropped quantization support in ConfigSpace 1.0 + # See Also: https://github.com/automl/ConfigSpace/issues/390 + if not hasattr(hp, "sample_value_mlos_orig"): + setattr(hp, "sample_value_mlos_orig", hp.sample_value) + + assert hasattr(hp, "sample_value_mlos_orig") + setattr( + hp, + "sample_value", + lambda size=None, **kwargs: quantize( + hp.sample_value_mlos_orig(size, **kwargs), + bounds=(hp.lower, hp.upper), + bins=quantization_bins, + ).astype(type(hp.default_value)), + ) + + def _tunable_to_configspace( tunable: Tunable, group_name: Optional[str] = None, @@ -77,6 +104,7 @@ def _tunable_to_configspace( cs : ConfigurationSpace A ConfigurationSpace object that corresponds to the Tunable. """ + # pylint: disable=too-complex meta: Dict[Hashable, TunableValue] = {"cost": cost} if group_name is not None: meta["group"] = group_name @@ -110,20 +138,12 @@ def _tunable_to_configspace( elif tunable.distribution is not None: raise TypeError(f"Invalid Distribution Type: {tunable.distribution}") - range_hp: Union[ - BetaFloatHyperparameter, - BetaIntegerHyperparameter, - NormalFloatHyperparameter, - NormalIntegerHyperparameter, - UniformFloatHyperparameter, - UniformIntegerHyperparameter, - ] + range_hp: NumericalHyperparameter if tunable.type == "int": range_hp = Integer( name=tunable.name, bounds=(int(tunable.range[0]), int(tunable.range[1])), log=bool(tunable.is_log), - # TODO: Restore quantization support (#803). distribution=distribution, default=( int(tunable.default) @@ -137,7 +157,6 @@ def _tunable_to_configspace( name=tunable.name, bounds=tunable.range, log=bool(tunable.is_log), - # TODO: Restore quantization support (#803). distribution=distribution, default=( float(tunable.default) @@ -149,6 +168,11 @@ def _tunable_to_configspace( else: raise TypeError(f"Invalid Parameter Type: {tunable.type}") + if tunable.quantization: + # Temporary workaround to dropped quantization support in ConfigSpace 1.0 + # See Also: https://github.com/automl/ConfigSpace/issues/390 + _monkey_patch_quantization(range_hp, tunable.quantization) + if not tunable.special: return ConfigurationSpace({tunable.name: range_hp}) diff --git a/mlos_bench/mlos_bench/optimizers/grid_search_optimizer.py b/mlos_bench/mlos_bench/optimizers/grid_search_optimizer.py index 8bcd090415e..0fc92096198 100644 --- a/mlos_bench/mlos_bench/optimizers/grid_search_optimizer.py +++ b/mlos_bench/mlos_bench/optimizers/grid_search_optimizer.py @@ -47,7 +47,7 @@ def __init__( self._suggested_configs: Set[Tuple[TunableValue, ...]] = set() def _sanity_check(self) -> None: - size = np.prod([tunable.cardinality for (tunable, _group) in self._tunables]) + size = np.prod([tunable.cardinality or np.inf for (tunable, _group) in self._tunables]) if size == np.inf: raise ValueError( f"Unquantized tunables are not supported for grid search: {self._tunables}" @@ -79,9 +79,9 @@ def _get_grid(self) -> Tuple[Tuple[str, ...], Dict[Tuple[TunableValue, ...], Non for config in generate_grid( self.config_space, { - tunable.name: int(tunable.cardinality) + tunable.name: tunable.cardinality or 0 # mypy wants an int for (tunable, _group) in self._tunables - if tunable.quantization or tunable.type == "int" + if tunable.is_numerical and tunable.cardinality }, ) ] diff --git a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-bad-float-quantization.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-bad-float-quantization.jsonc index 194682b8592..cff352e7b8d 100644 --- a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-bad-float-quantization.jsonc +++ b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-bad-float-quantization.jsonc @@ -6,7 +6,7 @@ "type": "float", "default": 10, "range": [1, 500], - "quantization": 0 // <-- should be greater than 0 + "quantization": 1 // <-- should be greater than 1 } } } diff --git a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/good/full/full-tunable-params-test.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/good/full/full-tunable-params-test.jsonc index ae7291e5fcc..6d83b248af2 100644 --- a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/good/full/full-tunable-params-test.jsonc +++ b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/good/full/full-tunable-params-test.jsonc @@ -7,7 +7,7 @@ "description": "Int", "type": "int", "default": 10, - "range": [1, 500], + "range": [0, 500], "meta": {"suffix": "MB"}, "special": [-1], "special_weights": [0.1], @@ -26,7 +26,7 @@ "description": "Int", "type": "int", "default": 10, - "range": [1, 500], + "range": [0, 500], "meta": {"suffix": "MB"}, "special": [-1], "special_weights": [0.1], @@ -48,7 +48,7 @@ "meta": {"scale": 1000, "prefix": "/proc/var/random/", "base": 2.71828}, "range": [1.1, 111.1], "special": [-1.1], - "quantization": 10, + "quantization": 11, "distribution": { "type": "uniform" }, diff --git a/mlos_bench/mlos_bench/tests/optimizers/grid_search_optimizer_test.py b/mlos_bench/mlos_bench/tests/optimizers/grid_search_optimizer_test.py index 769bf8859de..bf49cc82b50 100644 --- a/mlos_bench/mlos_bench/tests/optimizers/grid_search_optimizer_test.py +++ b/mlos_bench/mlos_bench/tests/optimizers/grid_search_optimizer_test.py @@ -9,6 +9,7 @@ import random from typing import Dict, List +import numpy as np import pytest from mlos_bench.environments.status import Status @@ -40,7 +41,7 @@ def grid_search_tunables_config() -> dict: "type": "float", "range": [0, 1], "default": 0.5, - "quantization": 0.25, + "quantization": 5, }, }, }, @@ -99,7 +100,9 @@ def test_grid_search_grid( ) -> None: """Make sure that grid search optimizer initializes and works correctly.""" # Check the size. - expected_grid_size = math.prod(tunable.cardinality for tunable, _group in grid_search_tunables) + expected_grid_size = math.prod( + tunable.cardinality or np.inf for tunable, _group in grid_search_tunables + ) assert expected_grid_size > len(grid_search_tunables) assert len(grid_search_tunables_grid) == expected_grid_size # Check for specific example configs inclusion. diff --git a/mlos_bench/mlos_bench/tests/tunable_groups_fixtures.py b/mlos_bench/mlos_bench/tests/tunable_groups_fixtures.py index 2c10e2ba750..e606496a496 100644 --- a/mlos_bench/mlos_bench/tests/tunable_groups_fixtures.py +++ b/mlos_bench/mlos_bench/tests/tunable_groups_fixtures.py @@ -62,6 +62,7 @@ "type": "int", "default": 2000000, "range": [0, 1000000000], + "quantization": 11, "log": false } } diff --git a/mlos_bench/mlos_bench/tests/tunables/test_tunables_size_props.py b/mlos_bench/mlos_bench/tests/tunables/test_tunables_size_props.py index fcbca29ed9c..cfc5b43bac5 100644 --- a/mlos_bench/mlos_bench/tests/tunables/test_tunables_size_props.py +++ b/mlos_bench/mlos_bench/tests/tunables/test_tunables_size_props.py @@ -4,7 +4,6 @@ # """Unit tests for checking tunable size properties.""" -import numpy as np import pytest from mlos_bench.tunables.tunable import Tunable @@ -23,9 +22,9 @@ def test_tunable_int_size_props() -> None: "default": 3, }, ) - assert tunable.span == 4 - assert tunable.cardinality == 5 expected = [1, 2, 3, 4, 5] + assert tunable.span == 4 + assert tunable.cardinality == len(expected) assert list(tunable.quantized_values or []) == expected assert list(tunable.values or []) == expected @@ -41,7 +40,7 @@ def test_tunable_float_size_props() -> None: }, ) assert tunable.span == 3.5 - assert tunable.cardinality == np.inf + assert tunable.cardinality is None assert tunable.quantized_values is None assert tunable.values is None @@ -68,11 +67,17 @@ def test_tunable_quantized_int_size_props() -> None: """Test quantized tunable int size properties.""" tunable = Tunable( name="test", - config={"type": "int", "range": [100, 1000], "default": 100, "quantization": 100}, + config={ + "type": "int", + "range": [100, 1000], + "default": 100, + "quantization": 10, + }, ) - assert tunable.span == 900 - assert tunable.cardinality == 10 expected = [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000] + assert tunable.span == 900 + assert tunable.cardinality == len(expected) + assert tunable.quantization == len(expected) assert list(tunable.quantized_values or []) == expected assert list(tunable.values or []) == expected @@ -81,10 +86,16 @@ def test_tunable_quantized_float_size_props() -> None: """Test quantized tunable float size properties.""" tunable = Tunable( name="test", - config={"type": "float", "range": [0, 1], "default": 0, "quantization": 0.1}, + config={ + "type": "float", + "range": [0, 1], + "default": 0, + "quantization": 11, + }, ) - assert tunable.span == 1 - assert tunable.cardinality == 11 expected = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] + assert tunable.span == 1 + assert tunable.cardinality == len(expected) + assert tunable.quantization == len(expected) assert pytest.approx(list(tunable.quantized_values or []), 0.0001) == expected assert pytest.approx(list(tunable.values or []), 0.0001) == expected diff --git a/mlos_bench/mlos_bench/tests/tunables/tunable_definition_test.py b/mlos_bench/mlos_bench/tests/tunables/tunable_definition_test.py index 7403841f8db..0ad08eefb6f 100644 --- a/mlos_bench/mlos_bench/tests/tunables/tunable_definition_test.py +++ b/mlos_bench/mlos_bench/tests/tunables/tunable_definition_test.py @@ -234,13 +234,15 @@ def test_numerical_quantization(tunable_type: TunableValueTypeName) -> None: {{ "type": "{tunable_type}", "range": [0, 100], - "quantization": 10, + "quantization": 11, "default": 0 }} """ config = json.loads(json_config) tunable = Tunable(name="test", config=config) - assert tunable.quantization == 10 + expected = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] + assert tunable.quantization == len(expected) + assert pytest.approx(list(tunable.quantized_values or []), 1e-8) == expected assert not tunable.is_log diff --git a/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_quant_test.py b/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_quant_test.py new file mode 100644 index 00000000000..606fcd9b770 --- /dev/null +++ b/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_quant_test.py @@ -0,0 +1,67 @@ +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +"""Unit tests for ConfigSpace quantization monkey patching.""" + +import numpy as np +from ConfigSpace import UniformFloatHyperparameter, UniformIntegerHyperparameter +from numpy.random import RandomState + +from mlos_bench.optimizers.convert_configspace import _monkey_patch_quantization +from mlos_bench.tests import SEED + + +def test_configspace_quant_int() -> None: + """Check the quantization of an integer hyperparameter.""" + quantized_values = set(range(0, 101, 10)) + hp = UniformIntegerHyperparameter("hp", lower=0, upper=100, log=False) + + # Before patching: expect that at least one value is not quantized. + assert not set(hp.sample_value(100)).issubset(quantized_values) + + _monkey_patch_quantization(hp, 11) + # After patching: *all* values must belong to the set of quantized values. + assert hp.sample_value() in quantized_values # check scalar type + assert set(hp.sample_value(100)).issubset(quantized_values) # batch version + + +def test_configspace_quant_float() -> None: + """Check the quantization of a float hyperparameter.""" + quantized_values = set(np.linspace(0, 1, num=5, endpoint=True)) + hp = UniformFloatHyperparameter("hp", lower=0, upper=1, log=False) + + # Before patching: expect that at least one value is not quantized. + assert not set(hp.sample_value(100)).issubset(quantized_values) + + # 5 is a nice number of bins to avoid floating point errors. + _monkey_patch_quantization(hp, 5) + # After patching: *all* values must belong to the set of quantized values. + assert hp.sample_value() in quantized_values # check scalar type + assert set(hp.sample_value(100)).issubset(quantized_values) # batch version + + +def test_configspace_quant_repatch() -> None: + """Repatch the same hyperparameter with different number of bins.""" + quantized_values = set(range(0, 101, 10)) + hp = UniformIntegerHyperparameter("hp", lower=0, upper=100, log=False) + + # Before patching: expect that at least one value is not quantized. + assert not set(hp.sample_value(100)).issubset(quantized_values) + + _monkey_patch_quantization(hp, 11) + # After patching: *all* values must belong to the set of quantized values. + samples = hp.sample_value(100, seed=RandomState(SEED)) + assert set(samples).issubset(quantized_values) + + # Patch the same hyperparameter again and check that the results are the same. + _monkey_patch_quantization(hp, 11) + # After patching: *all* values must belong to the set of quantized values. + assert all(samples == hp.sample_value(100, seed=RandomState(SEED))) + + # Repatch with the higher number of bins and make sure we get new values. + _monkey_patch_quantization(hp, 21) + samples_set = set(hp.sample_value(100, seed=RandomState(SEED))) + quantized_values_new = set(range(5, 96, 10)) + assert samples_set.issubset(set(range(0, 101, 5))) + assert len(samples_set - quantized_values_new) < len(samples_set) diff --git a/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_test.py b/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_test.py index 8e472a7ea5d..2b520002252 100644 --- a/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_test.py +++ b/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_test.py @@ -13,9 +13,11 @@ UniformFloatHyperparameter, UniformIntegerHyperparameter, ) +from ConfigSpace.hyperparameters import NumericalHyperparameter from mlos_bench.optimizers.convert_configspace import ( TunableValueKind, + _monkey_patch_quantization, _tunable_to_configspace, special_param_names, tunable_groups_to_configspace, @@ -41,8 +43,6 @@ def configuration_space() -> ConfigurationSpace: special_param_names("kernel_sched_migration_cost_ns") ) - # TODO: Add quantization support tests (#803). - # NOTE: FLAML requires distribution to be uniform spaces = ConfigurationSpace( { @@ -101,6 +101,9 @@ def configuration_space() -> ConfigurationSpace: TunableValueKind.RANGE, ) ) + hp = spaces["kernel_sched_latency_ns"] + assert isinstance(hp, NumericalHyperparameter) + _monkey_patch_quantization(hp, quantization_bins=10) return spaces diff --git a/mlos_bench/mlos_bench/tunables/tunable.py b/mlos_bench/mlos_bench/tunables/tunable.py index 8f9bb48bff9..b4a58e0a506 100644 --- a/mlos_bench/mlos_bench/tunables/tunable.py +++ b/mlos_bench/mlos_bench/tunables/tunable.py @@ -64,7 +64,7 @@ class TunableDict(TypedDict, total=False): default: TunableValue values: Optional[List[Optional[str]]] range: Optional[Union[Sequence[int], Sequence[float]]] - quantization: Optional[Union[int, float]] + quantization: Optional[int] log: Optional[bool] distribution: Optional[DistributionDict] special: Optional[Union[List[int], List[float]]] @@ -109,7 +109,7 @@ def __init__(self, name: str, config: TunableDict): self._values = [str(v) if v is not None else v for v in self._values] self._meta: Dict[str, Any] = config.get("meta", {}) self._range: Optional[Union[Tuple[int, int], Tuple[float, float]]] = None - self._quantization: Optional[Union[int, float]] = config.get("quantization") + self._quantization: Optional[int] = config.get("quantization") self._log: Optional[bool] = config.get("log") self._distribution: Optional[DistributionName] = None self._distribution_params: Dict[str, float] = {} @@ -182,19 +182,8 @@ def _sanity_check_numerical(self) -> None: raise ValueError(f"Values must be None for the numerical type tunable {self}") if not self._range or len(self._range) != 2 or self._range[0] >= self._range[1]: raise ValueError(f"Invalid range for tunable {self}: {self._range}") - if self._quantization is not None: - if self.dtype == int: - if not isinstance(self._quantization, int): - raise ValueError(f"Quantization of a int param should be an int: {self}") - if self._quantization <= 1: - raise ValueError(f"Number of quantization points is <= 1: {self}") - if self.dtype == float: - if not isinstance(self._quantization, (float, int)): - raise ValueError( - f"Quantization of a float param should be a float or int: {self}" - ) - if self._quantization <= 0: - raise ValueError(f"Number of quantization points is <= 0: {self}") + if self._quantization is not None and self._quantization <= 1: + raise ValueError(f"Number of quantization bins is <= 1: {self}") if self._distribution is not None and self._distribution not in { "uniform", "normal", @@ -391,7 +380,6 @@ def is_valid(self, value: TunableValue) -> bool: is_valid : bool True if the value is valid, False otherwise. """ - # FIXME: quantization check? if self.is_categorical and self._values: return value in self._values elif self.is_numerical and self._range: @@ -592,14 +580,14 @@ def span(self) -> Union[int, float]: return num_range[1] - num_range[0] @property - def quantization(self) -> Optional[Union[int, float]]: + def quantization(self) -> Optional[int]: """ - Get the quantization factor, if specified. + Get the number of quantization bins, if specified. Returns ------- - quantization : int, float, None - The quantization factor, or None. + quantization : int | None + Number of quantization bins, or None. """ if self.is_categorical: return None @@ -618,41 +606,45 @@ def quantized_values(self) -> Optional[Union[Iterable[int], Iterable[float]]]: """ num_range = self.range if self.type == "float": - if not self._quantization: + if not self.quantization: return None # Be sure to return python types instead of numpy types. - cardinality = self.cardinality - assert isinstance(cardinality, int) return ( float(x) for x in np.linspace( start=num_range[0], stop=num_range[1], - num=cardinality, + num=self.quantization, endpoint=True, ) ) assert self.type == "int", f"Unhandled tunable type: {self}" - return range(int(num_range[0]), int(num_range[1]) + 1, int(self._quantization or 1)) + return range( + int(num_range[0]), + int(num_range[1]) + 1, + int(self.span / (self.quantization - 1)) if self.quantization else 1, + ) @property - def cardinality(self) -> Union[int, float]: + def cardinality(self) -> Optional[int]: """ - Gets the cardinality of elements in this tunable, or else infinity. + Gets the cardinality of elements in this tunable, or else None. (i.e., when the + tunable is continuous float and not quantized). If the tunable has quantization set, this Returns ------- - cardinality : int, float - Either the number of points in the tunable or else infinity. + cardinality : int + Either the number of points in the tunable or else None. """ if self.is_categorical: return len(self.categories) - if not self.quantization and self.type == "float": - return np.inf - q_factor = self.quantization or 1 - return int(self.span / q_factor) + 1 + if self.quantization: + return self.quantization + if self.type == "int": + return int(self.span) + 1 + return None @property def is_log(self) -> Optional[bool]: From fadfacb9bd1b4acb848f04e5929749935b7edf44 Mon Sep 17 00:00:00 2001 From: Sergiy Matusevych Date: Fri, 16 Aug 2024 10:01:49 -0700 Subject: [PATCH 04/36] Rename `quantization` -> `quantization_bins` (#844) Merge after (or instead of) #835 diff from #835 :: https://github.com/motus/MLOS/pull/15/files Closes #803 --------- Co-authored-by: Brian Kroth Co-authored-by: Brian Kroth --- .../tunables/tunable-params-schema.json | 12 +++++----- .../optimizers/convert_configspace.py | 4 ++-- ...e-params-float-bad-quantization-type.jsonc | 2 +- ...le-params-int-bad-float-quantization.jsonc | 2 +- ...able-params-int-bad-int-quantization.jsonc | 2 +- ...e-params-int-wrong-quantization-type.jsonc | 2 +- .../good/full/full-tunable-params-test.jsonc | 6 ++--- .../optimizers/grid_search_optimizer_test.py | 2 +- .../tests/tunable_groups_fixtures.py | 2 +- .../tunables/test_tunables_size_props.py | 8 +++---- .../tests/tunables/tunable_definition_test.py | 6 ++--- mlos_bench/mlos_bench/tunables/tunable.py | 24 +++++++++---------- 12 files changed, 36 insertions(+), 36 deletions(-) diff --git a/mlos_bench/mlos_bench/config/schemas/tunables/tunable-params-schema.json b/mlos_bench/mlos_bench/config/schemas/tunables/tunable-params-schema.json index fd6c61b91d4..e278c797a41 100644 --- a/mlos_bench/mlos_bench/config/schemas/tunables/tunable-params-schema.json +++ b/mlos_bench/mlos_bench/config/schemas/tunables/tunable-params-schema.json @@ -123,7 +123,7 @@ "maxItems": 2, "uniqueItems": true }, - "quantization": { + "quantization_bins": { "description": "The number of buckets to quantize the range into.", "type": "integer", "exclusiveMinimum": 1 @@ -187,7 +187,7 @@ }, "required": ["type", "default", "values"], "not": { - "required": ["range", "special", "special_weights", "range_weight", "log", "quantization", "distribution"] + "required": ["range", "special", "special_weights", "range_weight", "log", "quantization_bins", "distribution"] }, "$comment": "TODO: add check that default is in values", "unevaluatedProperties": false @@ -217,8 +217,8 @@ "distribution": { "$ref": "#/$defs/tunable_param_distribution" }, - "quantization": { - "$ref": "#/$defs/quantization" + "quantization_bins": { + "$ref": "#/$defs/quantization_bins" }, "log": { "$ref": "#/$defs/log_scale" @@ -265,8 +265,8 @@ "distribution": { "$ref": "#/$defs/tunable_param_distribution" }, - "quantization": { - "$ref": "#/$defs/quantization" + "quantization_bins": { + "$ref": "#/$defs/quantization_bins" }, "log": { "$ref": "#/$defs/log_scale" diff --git a/mlos_bench/mlos_bench/optimizers/convert_configspace.py b/mlos_bench/mlos_bench/optimizers/convert_configspace.py index ad7968cf69a..52623cee330 100644 --- a/mlos_bench/mlos_bench/optimizers/convert_configspace.py +++ b/mlos_bench/mlos_bench/optimizers/convert_configspace.py @@ -168,10 +168,10 @@ def _tunable_to_configspace( else: raise TypeError(f"Invalid Parameter Type: {tunable.type}") - if tunable.quantization: + if tunable.quantization_bins: # Temporary workaround to dropped quantization support in ConfigSpace 1.0 # See Also: https://github.com/automl/ConfigSpace/issues/390 - _monkey_patch_quantization(range_hp, tunable.quantization) + _monkey_patch_quantization(range_hp, tunable.quantization_bins) if not tunable.special: return ConfigurationSpace({tunable.name: range_hp}) diff --git a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-float-bad-quantization-type.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-float-bad-quantization-type.jsonc index 0ed88844574..379356b5560 100644 --- a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-float-bad-quantization-type.jsonc +++ b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-float-bad-quantization-type.jsonc @@ -6,7 +6,7 @@ "type": "float", "default": 10, "range": [0, 10], - "quantization": true // <-- this is invalid + "quantization_bins": true // <-- this is invalid } } } diff --git a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-bad-float-quantization.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-bad-float-quantization.jsonc index cff352e7b8d..27a3986df41 100644 --- a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-bad-float-quantization.jsonc +++ b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-bad-float-quantization.jsonc @@ -6,7 +6,7 @@ "type": "float", "default": 10, "range": [1, 500], - "quantization": 1 // <-- should be greater than 1 + "quantization_bins": 1 // <-- should be greater than 1 } } } diff --git a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-bad-int-quantization.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-bad-int-quantization.jsonc index 199cf681ca3..df648be385c 100644 --- a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-bad-int-quantization.jsonc +++ b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-bad-int-quantization.jsonc @@ -6,7 +6,7 @@ "type": "int", "default": 10, "range": [1, 500], - "quantization": 1 // <-- should be greater than 1 + "quantization_bins": 1 // <-- should be greater than 1 } } } diff --git a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-wrong-quantization-type.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-wrong-quantization-type.jsonc index 1b7af4ffcd0..7ed215b363f 100644 --- a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-wrong-quantization-type.jsonc +++ b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/bad/invalid/tunable-params-int-wrong-quantization-type.jsonc @@ -6,7 +6,7 @@ "type": "int", "default": 10, "range": [1, 500], - "quantization": "yes" // <-- this is invalid + "quantization_bins": "yes" // <-- this is invalid } } } diff --git a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/good/full/full-tunable-params-test.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/good/full/full-tunable-params-test.jsonc index 6d83b248af2..5e045e4171b 100644 --- a/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/good/full/full-tunable-params-test.jsonc +++ b/mlos_bench/mlos_bench/tests/config/schemas/tunable-params/test-cases/good/full/full-tunable-params-test.jsonc @@ -12,7 +12,7 @@ "special": [-1], "special_weights": [0.1], "range_weight": 0.9, - "quantization": 50, + "quantization_bins": 50, "distribution": { "type": "beta", "params": { @@ -31,7 +31,7 @@ "special": [-1], "special_weights": [0.1], "range_weight": 0.9, - "quantization": 50, + "quantization_bins": 50, "distribution": { "type": "normal", "params": { @@ -48,7 +48,7 @@ "meta": {"scale": 1000, "prefix": "/proc/var/random/", "base": 2.71828}, "range": [1.1, 111.1], "special": [-1.1], - "quantization": 11, + "quantization_bins": 11, "distribution": { "type": "uniform" }, diff --git a/mlos_bench/mlos_bench/tests/optimizers/grid_search_optimizer_test.py b/mlos_bench/mlos_bench/tests/optimizers/grid_search_optimizer_test.py index bf49cc82b50..703381e4882 100644 --- a/mlos_bench/mlos_bench/tests/optimizers/grid_search_optimizer_test.py +++ b/mlos_bench/mlos_bench/tests/optimizers/grid_search_optimizer_test.py @@ -41,7 +41,7 @@ def grid_search_tunables_config() -> dict: "type": "float", "range": [0, 1], "default": 0.5, - "quantization": 5, + "quantization_bins": 5, }, }, }, diff --git a/mlos_bench/mlos_bench/tests/tunable_groups_fixtures.py b/mlos_bench/mlos_bench/tests/tunable_groups_fixtures.py index e606496a496..59082fac9e6 100644 --- a/mlos_bench/mlos_bench/tests/tunable_groups_fixtures.py +++ b/mlos_bench/mlos_bench/tests/tunable_groups_fixtures.py @@ -62,7 +62,7 @@ "type": "int", "default": 2000000, "range": [0, 1000000000], - "quantization": 11, + "quantization_bins": 11, "log": false } } diff --git a/mlos_bench/mlos_bench/tests/tunables/test_tunables_size_props.py b/mlos_bench/mlos_bench/tests/tunables/test_tunables_size_props.py index cfc5b43bac5..c3344f6988a 100644 --- a/mlos_bench/mlos_bench/tests/tunables/test_tunables_size_props.py +++ b/mlos_bench/mlos_bench/tests/tunables/test_tunables_size_props.py @@ -71,13 +71,13 @@ def test_tunable_quantized_int_size_props() -> None: "type": "int", "range": [100, 1000], "default": 100, - "quantization": 10, + "quantization_bins": 10, }, ) expected = [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000] assert tunable.span == 900 assert tunable.cardinality == len(expected) - assert tunable.quantization == len(expected) + assert tunable.quantization_bins == len(expected) assert list(tunable.quantized_values or []) == expected assert list(tunable.values or []) == expected @@ -90,12 +90,12 @@ def test_tunable_quantized_float_size_props() -> None: "type": "float", "range": [0, 1], "default": 0, - "quantization": 11, + "quantization_bins": 11, }, ) expected = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] assert tunable.span == 1 assert tunable.cardinality == len(expected) - assert tunable.quantization == len(expected) + assert tunable.quantization_bins == len(expected) assert pytest.approx(list(tunable.quantized_values or []), 0.0001) == expected assert pytest.approx(list(tunable.values or []), 0.0001) == expected diff --git a/mlos_bench/mlos_bench/tests/tunables/tunable_definition_test.py b/mlos_bench/mlos_bench/tests/tunables/tunable_definition_test.py index 0ad08eefb6f..a7b12c26921 100644 --- a/mlos_bench/mlos_bench/tests/tunables/tunable_definition_test.py +++ b/mlos_bench/mlos_bench/tests/tunables/tunable_definition_test.py @@ -234,14 +234,14 @@ def test_numerical_quantization(tunable_type: TunableValueTypeName) -> None: {{ "type": "{tunable_type}", "range": [0, 100], - "quantization": 11, + "quantization_bins": 11, "default": 0 }} """ config = json.loads(json_config) tunable = Tunable(name="test", config=config) expected = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] - assert tunable.quantization == len(expected) + assert tunable.quantization_bins == len(expected) assert pytest.approx(list(tunable.quantized_values or []), 1e-8) == expected assert not tunable.is_log @@ -393,7 +393,7 @@ def test_numerical_quantization_wrong(tunable_type: TunableValueTypeName) -> Non {{ "type": "{tunable_type}", "range": [0, 100], - "quantization": 0, + "quantization_bins": 0, "default": 0 }} """ diff --git a/mlos_bench/mlos_bench/tunables/tunable.py b/mlos_bench/mlos_bench/tunables/tunable.py index b4a58e0a506..4d2781ad11b 100644 --- a/mlos_bench/mlos_bench/tunables/tunable.py +++ b/mlos_bench/mlos_bench/tunables/tunable.py @@ -64,7 +64,7 @@ class TunableDict(TypedDict, total=False): default: TunableValue values: Optional[List[Optional[str]]] range: Optional[Union[Sequence[int], Sequence[float]]] - quantization: Optional[int] + quantization_bins: Optional[int] log: Optional[bool] distribution: Optional[DistributionDict] special: Optional[Union[List[int], List[float]]] @@ -109,7 +109,7 @@ def __init__(self, name: str, config: TunableDict): self._values = [str(v) if v is not None else v for v in self._values] self._meta: Dict[str, Any] = config.get("meta", {}) self._range: Optional[Union[Tuple[int, int], Tuple[float, float]]] = None - self._quantization: Optional[int] = config.get("quantization") + self._quantization_bins: Optional[int] = config.get("quantization_bins") self._log: Optional[bool] = config.get("log") self._distribution: Optional[DistributionName] = None self._distribution_params: Dict[str, float] = {} @@ -162,7 +162,7 @@ def _sanity_check_categorical(self) -> None: raise ValueError(f"Categorical tunable cannot have range_weight: {self}") if self._log is not None: raise ValueError(f"Categorical tunable cannot have log parameter: {self}") - if self._quantization is not None: + if self._quantization_bins is not None: raise ValueError(f"Categorical tunable cannot have quantization parameter: {self}") if self._distribution is not None: raise ValueError(f"Categorical parameters do not support `distribution`: {self}") @@ -182,7 +182,7 @@ def _sanity_check_numerical(self) -> None: raise ValueError(f"Values must be None for the numerical type tunable {self}") if not self._range or len(self._range) != 2 or self._range[0] >= self._range[1]: raise ValueError(f"Invalid range for tunable {self}: {self._range}") - if self._quantization is not None and self._quantization <= 1: + if self._quantization_bins is not None and self._quantization_bins <= 1: raise ValueError(f"Number of quantization bins is <= 1: {self}") if self._distribution is not None and self._distribution not in { "uniform", @@ -580,18 +580,18 @@ def span(self) -> Union[int, float]: return num_range[1] - num_range[0] @property - def quantization(self) -> Optional[int]: + def quantization_bins(self) -> Optional[int]: """ Get the number of quantization bins, if specified. Returns ------- - quantization : int | None + quantization_bins : int | None Number of quantization bins, or None. """ if self.is_categorical: return None - return self._quantization + return self._quantization_bins @property def quantized_values(self) -> Optional[Union[Iterable[int], Iterable[float]]]: @@ -606,7 +606,7 @@ def quantized_values(self) -> Optional[Union[Iterable[int], Iterable[float]]]: """ num_range = self.range if self.type == "float": - if not self.quantization: + if not self.quantization_bins: return None # Be sure to return python types instead of numpy types. return ( @@ -614,7 +614,7 @@ def quantized_values(self) -> Optional[Union[Iterable[int], Iterable[float]]]: for x in np.linspace( start=num_range[0], stop=num_range[1], - num=self.quantization, + num=self.quantization_bins, endpoint=True, ) ) @@ -622,7 +622,7 @@ def quantized_values(self) -> Optional[Union[Iterable[int], Iterable[float]]]: return range( int(num_range[0]), int(num_range[1]) + 1, - int(self.span / (self.quantization - 1)) if self.quantization else 1, + int(self.span / (self.quantization_bins - 1)) if self.quantization_bins else 1, ) @property @@ -640,8 +640,8 @@ def cardinality(self) -> Optional[int]: """ if self.is_categorical: return len(self.categories) - if self.quantization: - return self.quantization + if self.quantization_bins: + return self.quantization_bins if self.type == "int": return int(self.span) + 1 return None From 6134b146129d2244feaeea1a03b553916a449f3f Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Fri, 16 Aug 2024 12:58:38 -0500 Subject: [PATCH 05/36] =?UTF-8?q?Bump=20version:=200.6.0=20=E2=86=92=200.6?= =?UTF-8?q?.1=20(#845)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump version after #844 which represents a breaking config schema change. --- .bumpversion.cfg | 2 +- doc/source/version.py | 2 +- mlos_bench/mlos_bench/version.py | 2 +- mlos_core/mlos_core/version.py | 2 +- mlos_viz/mlos_viz/version.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 44468a477ec..f5326c2c01e 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.6.0 +current_version = 0.6.1 commit = True tag = True diff --git a/doc/source/version.py b/doc/source/version.py index 53c4de37f3b..77a706ac1e4 100644 --- a/doc/source/version.py +++ b/doc/source/version.py @@ -7,7 +7,7 @@ """ # NOTE: This should be managed by bumpversion. -VERSION = '0.6.0' +VERSION = '0.6.1' if __name__ == "__main__": print(VERSION) diff --git a/mlos_bench/mlos_bench/version.py b/mlos_bench/mlos_bench/version.py index 58b3db26e06..9bb7876ac4b 100644 --- a/mlos_bench/mlos_bench/version.py +++ b/mlos_bench/mlos_bench/version.py @@ -5,7 +5,7 @@ """Version number for the mlos_bench package.""" # NOTE: This should be managed by bumpversion. -VERSION = "0.6.0" +VERSION = "0.6.1" if __name__ == "__main__": print(VERSION) diff --git a/mlos_core/mlos_core/version.py b/mlos_core/mlos_core/version.py index 8990c831701..64b1c52022f 100644 --- a/mlos_core/mlos_core/version.py +++ b/mlos_core/mlos_core/version.py @@ -5,7 +5,7 @@ """Version number for the mlos_core package.""" # NOTE: This should be managed by bumpversion. -VERSION = "0.6.0" +VERSION = "0.6.1" if __name__ == "__main__": print(VERSION) diff --git a/mlos_viz/mlos_viz/version.py b/mlos_viz/mlos_viz/version.py index 3daffe21ed1..a38eddb9e9e 100644 --- a/mlos_viz/mlos_viz/version.py +++ b/mlos_viz/mlos_viz/version.py @@ -5,7 +5,7 @@ """Version number for the mlos_viz package.""" # NOTE: This should be managed by bumpversion. -VERSION = "0.6.0" +VERSION = "0.6.1" if __name__ == "__main__": print(VERSION) From 8afe9c37c2fba1ba11e1bb2fa8f445c59eb0214a Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Fri, 16 Aug 2024 13:17:27 -0500 Subject: [PATCH 06/36] Devcontainer tweaks (#846) Try to add github.com ssh keys to the ssh known_hosts file to avoid prompting in scripted operations in downstream consumers. --- .devcontainer/Dockerfile | 7 +++++++ .github/workflows/devcontainer.yml | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index cf4b37aca9d..8702d314cbc 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -92,3 +92,10 @@ RUN umask 0002 \ && mkdir -p /opt/conda/pkgs/cache/ && chown -R vscode:conda /opt/conda/pkgs/cache/ RUN mkdir -p /home/vscode/.conda/envs \ && ln -s /opt/conda/envs/mlos /home/vscode/.conda/envs/mlos + +# Try and prime the devcontainer's ssh known_hosts keys with the github one for scripted calls. +RUN mkdir -p /home/vscode/.ssh \ + && ( \ + grep -q ^github.com /home/vscode/.ssh/known_hosts \ + || ssh-keyscan github.com | tee -a /home/vscode/.ssh/known_hosts \ + ) diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index c07a902fc6c..b57257a9075 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -128,6 +128,10 @@ jobs: run: | docker exec --user vscode --env USER=vscode mlos-devcontainer printenv + - name: Check that github.com is in the ssh known_hosts file + run: | + docker exec --user vscode --env USER=vscode mlos-devcontainer grep ^github.com /home/vscode/.ssh/known_hosts + - name: Update the conda env in the devcontainer timeout-minutes: 10 run: | From 9183ae6308c7f042493e98bff4d44f7cec7cdb13 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Mon, 19 Aug 2024 17:43:15 -0500 Subject: [PATCH 07/36] Add llamatune checks with quantized values (#814) Follow on to #812 Closes #813 --------- Co-authored-by: Sergiy Matusevych Co-authored-by: Eu Jing Chua Co-authored-by: Eu Jing Chua Co-authored-by: Sergiy Matusevych --- .../optimizers/convert_configspace.py | 35 +---------------- .../tunables/tunable_to_configspace_test.py | 4 +- mlos_core/mlos_core/spaces/converters/util.py | 39 +++++++++++++++++++ .../tests/spaces/adapters/llamatune_test.py | 30 +++++++++++++- .../spaces/monkey_patch_quantization_test.py | 14 +++---- 5 files changed, 79 insertions(+), 43 deletions(-) create mode 100644 mlos_core/mlos_core/spaces/converters/util.py rename mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_quant_test.py => mlos_core/mlos_core/tests/spaces/monkey_patch_quantization_test.py (89%) diff --git a/mlos_bench/mlos_bench/optimizers/convert_configspace.py b/mlos_bench/mlos_bench/optimizers/convert_configspace.py index 52623cee330..4d3deb59269 100644 --- a/mlos_bench/mlos_bench/optimizers/convert_configspace.py +++ b/mlos_bench/mlos_bench/optimizers/convert_configspace.py @@ -20,13 +20,13 @@ Normal, Uniform, ) -from ConfigSpace.functional import quantize from ConfigSpace.hyperparameters import NumericalHyperparameter from ConfigSpace.types import NotSet from mlos_bench.tunables.tunable import Tunable, TunableValue from mlos_bench.tunables.tunable_groups import TunableGroups from mlos_bench.util import try_parse_val +from mlos_core.spaces.converters.util import monkey_patch_quantization _LOG = logging.getLogger(__name__) @@ -49,37 +49,6 @@ def _normalize_weights(weights: List[float]) -> List[float]: return [w / total for w in weights] -def _monkey_patch_quantization(hp: NumericalHyperparameter, quantization_bins: int) -> None: - """ - Monkey-patch quantization into the Hyperparameter. - - Parameters - ---------- - hp : NumericalHyperparameter - ConfigSpace hyperparameter to patch. - quantization_bins : int - Number of bins to quantize the hyperparameter into. - """ - if quantization_bins <= 1: - raise ValueError(f"{quantization_bins=} :: must be greater than 1.") - - # Temporary workaround to dropped quantization support in ConfigSpace 1.0 - # See Also: https://github.com/automl/ConfigSpace/issues/390 - if not hasattr(hp, "sample_value_mlos_orig"): - setattr(hp, "sample_value_mlos_orig", hp.sample_value) - - assert hasattr(hp, "sample_value_mlos_orig") - setattr( - hp, - "sample_value", - lambda size=None, **kwargs: quantize( - hp.sample_value_mlos_orig(size, **kwargs), - bounds=(hp.lower, hp.upper), - bins=quantization_bins, - ).astype(type(hp.default_value)), - ) - - def _tunable_to_configspace( tunable: Tunable, group_name: Optional[str] = None, @@ -171,7 +140,7 @@ def _tunable_to_configspace( if tunable.quantization_bins: # Temporary workaround to dropped quantization support in ConfigSpace 1.0 # See Also: https://github.com/automl/ConfigSpace/issues/390 - _monkey_patch_quantization(range_hp, tunable.quantization_bins) + monkey_patch_quantization(range_hp, tunable.quantization_bins) if not tunable.special: return ConfigurationSpace({tunable.name: range_hp}) diff --git a/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_test.py b/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_test.py index 2b520002252..e33ee3fc9c3 100644 --- a/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_test.py +++ b/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_test.py @@ -17,13 +17,13 @@ from mlos_bench.optimizers.convert_configspace import ( TunableValueKind, - _monkey_patch_quantization, _tunable_to_configspace, special_param_names, tunable_groups_to_configspace, ) from mlos_bench.tunables.tunable import Tunable from mlos_bench.tunables.tunable_groups import TunableGroups +from mlos_core.spaces.converters.util import monkey_patch_quantization # pylint: disable=redefined-outer-name @@ -103,7 +103,7 @@ def configuration_space() -> ConfigurationSpace: ) hp = spaces["kernel_sched_latency_ns"] assert isinstance(hp, NumericalHyperparameter) - _monkey_patch_quantization(hp, quantization_bins=10) + monkey_patch_quantization(hp, quantization_bins=11) return spaces diff --git a/mlos_core/mlos_core/spaces/converters/util.py b/mlos_core/mlos_core/spaces/converters/util.py new file mode 100644 index 00000000000..23eb223584f --- /dev/null +++ b/mlos_core/mlos_core/spaces/converters/util.py @@ -0,0 +1,39 @@ +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +"""Helper functions for config space converters.""" + +from ConfigSpace.functional import quantize +from ConfigSpace.hyperparameters import NumericalHyperparameter + + +def monkey_patch_quantization(hp: NumericalHyperparameter, quantization_bins: int) -> None: + """ + Monkey-patch quantization into the Hyperparameter. + + Parameters + ---------- + hp : NumericalHyperparameter + ConfigSpace hyperparameter to patch. + quantization_bins : int + Number of bins to quantize the hyperparameter into. + """ + if quantization_bins <= 1: + raise ValueError(f"{quantization_bins=} :: must be greater than 1.") + + # Temporary workaround to dropped quantization support in ConfigSpace 1.0 + # See Also: https://github.com/automl/ConfigSpace/issues/390 + if not hasattr(hp, "sample_value_mlos_orig"): + setattr(hp, "sample_value_mlos_orig", hp.sample_value) + + assert hasattr(hp, "sample_value_mlos_orig") + setattr( + hp, + "sample_value", + lambda size=None, **kwargs: quantize( + hp.sample_value_mlos_orig(size, **kwargs), + bounds=(hp.lower, hp.upper), + bins=quantization_bins, + ).astype(type(hp.default_value)), + ) diff --git a/mlos_core/mlos_core/tests/spaces/adapters/llamatune_test.py b/mlos_core/mlos_core/tests/spaces/adapters/llamatune_test.py index fc42a0234ec..f9718dc00cc 100644 --- a/mlos_core/mlos_core/tests/spaces/adapters/llamatune_test.py +++ b/mlos_core/mlos_core/tests/spaces/adapters/llamatune_test.py @@ -13,11 +13,18 @@ import pytest from mlos_core.spaces.adapters import LlamaTuneAdapter +from mlos_core.spaces.converters.util import monkey_patch_quantization +# Explicitly test quantized values with llamatune space adapter. +# TODO: Add log scale sampling tests as well. -def construct_parameter_space( + +def construct_parameter_space( # pylint: disable=too-many-arguments + *, n_continuous_params: int = 0, + n_quantized_continuous_params: int = 0, n_integer_params: int = 0, + n_quantized_integer_params: int = 0, n_categorical_params: int = 0, seed: int = 1234, ) -> CS.ConfigurationSpace: @@ -26,8 +33,16 @@ def construct_parameter_space( for idx in range(n_continuous_params): input_space.add(CS.UniformFloatHyperparameter(name=f"cont_{idx}", lower=0, upper=64)) + for idx in range(n_quantized_continuous_params): + param_int = CS.UniformFloatHyperparameter(name=f"cont_{idx}", lower=0, upper=64) + monkey_patch_quantization(param_int, 6) + input_space.add(param_int) for idx in range(n_integer_params): input_space.add(CS.UniformIntegerHyperparameter(name=f"int_{idx}", lower=-1, upper=256)) + for idx in range(n_quantized_integer_params): + param_float = CS.UniformIntegerHyperparameter(name=f"int_{idx}", lower=0, upper=256) + monkey_patch_quantization(param_float, 17) + input_space.add(param_float) for idx in range(n_categorical_params): input_space.add( CS.CategoricalHyperparameter( @@ -49,6 +64,13 @@ def construct_parameter_space( {"n_continuous_params": int(num_target_space_dims * num_orig_space_factor)}, {"n_integer_params": int(num_target_space_dims * num_orig_space_factor)}, {"n_categorical_params": int(num_target_space_dims * num_orig_space_factor)}, + {"n_categorical_params": int(num_target_space_dims * num_orig_space_factor)}, + {"n_quantized_integer_params": int(num_target_space_dims * num_orig_space_factor)}, + { + "n_quantized_continuous_params": int( + num_target_space_dims * num_orig_space_factor + ) + }, # Mix of all three types { "n_continuous_params": int(num_target_space_dims * num_orig_space_factor / 3), @@ -358,6 +380,12 @@ def test_max_unique_values_per_param() -> None: {"n_continuous_params": int(num_target_space_dims * num_orig_space_factor)}, {"n_integer_params": int(num_target_space_dims * num_orig_space_factor)}, {"n_categorical_params": int(num_target_space_dims * num_orig_space_factor)}, + {"n_quantized_integer_params": int(num_target_space_dims * num_orig_space_factor)}, + { + "n_quantized_continuous_params": int( + num_target_space_dims * num_orig_space_factor + ) + }, # Mix of all three types { "n_continuous_params": int(num_target_space_dims * num_orig_space_factor / 3), diff --git a/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_quant_test.py b/mlos_core/mlos_core/tests/spaces/monkey_patch_quantization_test.py similarity index 89% rename from mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_quant_test.py rename to mlos_core/mlos_core/tests/spaces/monkey_patch_quantization_test.py index 606fcd9b770..d50fe7374e6 100644 --- a/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_quant_test.py +++ b/mlos_core/mlos_core/tests/spaces/monkey_patch_quantization_test.py @@ -8,8 +8,8 @@ from ConfigSpace import UniformFloatHyperparameter, UniformIntegerHyperparameter from numpy.random import RandomState -from mlos_bench.optimizers.convert_configspace import _monkey_patch_quantization -from mlos_bench.tests import SEED +from mlos_core.spaces.converters.util import monkey_patch_quantization +from mlos_core.tests import SEED def test_configspace_quant_int() -> None: @@ -20,7 +20,7 @@ def test_configspace_quant_int() -> None: # Before patching: expect that at least one value is not quantized. assert not set(hp.sample_value(100)).issubset(quantized_values) - _monkey_patch_quantization(hp, 11) + monkey_patch_quantization(hp, 11) # After patching: *all* values must belong to the set of quantized values. assert hp.sample_value() in quantized_values # check scalar type assert set(hp.sample_value(100)).issubset(quantized_values) # batch version @@ -35,7 +35,7 @@ def test_configspace_quant_float() -> None: assert not set(hp.sample_value(100)).issubset(quantized_values) # 5 is a nice number of bins to avoid floating point errors. - _monkey_patch_quantization(hp, 5) + monkey_patch_quantization(hp, 5) # After patching: *all* values must belong to the set of quantized values. assert hp.sample_value() in quantized_values # check scalar type assert set(hp.sample_value(100)).issubset(quantized_values) # batch version @@ -49,18 +49,18 @@ def test_configspace_quant_repatch() -> None: # Before patching: expect that at least one value is not quantized. assert not set(hp.sample_value(100)).issubset(quantized_values) - _monkey_patch_quantization(hp, 11) + monkey_patch_quantization(hp, 11) # After patching: *all* values must belong to the set of quantized values. samples = hp.sample_value(100, seed=RandomState(SEED)) assert set(samples).issubset(quantized_values) # Patch the same hyperparameter again and check that the results are the same. - _monkey_patch_quantization(hp, 11) + monkey_patch_quantization(hp, 11) # After patching: *all* values must belong to the set of quantized values. assert all(samples == hp.sample_value(100, seed=RandomState(SEED))) # Repatch with the higher number of bins and make sure we get new values. - _monkey_patch_quantization(hp, 21) + monkey_patch_quantization(hp, 21) samples_set = set(hp.sample_value(100, seed=RandomState(SEED))) quantized_values_new = set(range(5, 96, 10)) assert samples_set.issubset(set(range(0, 101, 5))) From 439df9933616150909677ffc298017803587d6d2 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Mon, 19 Aug 2024 18:02:50 -0500 Subject: [PATCH 08/36] Finish converting max_iterations to max_suggestions (#848) See Also: https://github.com/microsoft/MLOS/pull/713 --------- Co-authored-by: Sergiy Matusevych --- .../optimizers/mlos_core-optimizer-subschema.json | 2 +- mlos_bench/mlos_bench/optimizers/base_optimizer.py | 11 ++++------- .../mlos_bench/optimizers/grid_search_optimizer.py | 8 ++++---- .../mlos_bench/optimizers/mlos_core_optimizer.py | 11 ++++++----- .../mlos_bench/optimizers/one_shot_optimizer.py | 2 +- .../mlos_bench/tests/launcher_parse_args_test.py | 6 +++--- mlos_bench/mlos_bench/tests/optimizers/conftest.py | 2 +- .../tests/optimizers/grid_search_optimizer_test.py | 8 ++++---- 8 files changed, 24 insertions(+), 26 deletions(-) diff --git a/mlos_bench/mlos_bench/config/schemas/optimizers/mlos_core-optimizer-subschema.json b/mlos_bench/mlos_bench/config/schemas/optimizers/mlos_core-optimizer-subschema.json index 9088d77a9a1..fc71bd0d368 100644 --- a/mlos_bench/mlos_bench/config/schemas/optimizers/mlos_core-optimizer-subschema.json +++ b/mlos_bench/mlos_bench/config/schemas/optimizers/mlos_core-optimizer-subschema.json @@ -54,7 +54,7 @@ "example": 10 }, "max_trials": { - "description": "Influence the budget of max number of trials for SMAC. If omitted, will default to max_iterations.", + "description": "Influence the budget of max number of trials for SMAC. If omitted, will default to max_suggestions.", "type": "integer", "minimum": 10, "example": 100 diff --git a/mlos_bench/mlos_bench/optimizers/base_optimizer.py b/mlos_bench/mlos_bench/optimizers/base_optimizer.py index 42eeb8e3020..dad2ba507fd 100644 --- a/mlos_bench/mlos_bench/optimizers/base_optimizer.py +++ b/mlos_bench/mlos_bench/optimizers/base_optimizer.py @@ -78,7 +78,7 @@ def __init__( self._start_with_defaults: bool = bool( strtobool(str(self._config.pop("start_with_defaults", True))) ) - self._max_iter = int(self._config.pop("max_suggestions", 100)) + self._max_suggestions = int(self._config.pop("max_suggestions", 100)) opt_targets: Dict[str, str] = self._config.pop("optimization_targets", {"score": "min"}) self._opt_targets: Dict[str, Literal[1, -1]] = {} @@ -142,18 +142,15 @@ def current_iteration(self) -> int: """ return self._iter - # TODO: finish renaming iterations to suggestions. - # See Also: https://github.com/microsoft/MLOS/pull/713 - @property - def max_iterations(self) -> int: + def max_suggestions(self) -> int: """ The maximum number of iterations (suggestions) to run. Note: this may or may not be the same as the number of configurations. See Also: Scheduler.trial_config_repeat_count and Scheduler.max_trials. """ - return self._max_iter + return self._max_suggestions @property def seed(self) -> int: @@ -362,7 +359,7 @@ def not_converged(self) -> bool: Base implementation just checks the iteration count. """ - return self._iter < self._max_iter + return self._iter < self._max_suggestions @abstractmethod def get_best_observation( diff --git a/mlos_bench/mlos_bench/optimizers/grid_search_optimizer.py b/mlos_bench/mlos_bench/optimizers/grid_search_optimizer.py index 0fc92096198..c72fb2b0eb7 100644 --- a/mlos_bench/mlos_bench/optimizers/grid_search_optimizer.py +++ b/mlos_bench/mlos_bench/optimizers/grid_search_optimizer.py @@ -58,11 +58,11 @@ def _sanity_check(self) -> None: size, self._tunables, ) - if size > self._max_iter: + if size > self._max_suggestions: _LOG.warning( "Grid search size %d, is greater than max iterations %d", size, - self._max_iter, + self._max_suggestions, ) def _get_grid(self) -> Tuple[Tuple[str, ...], Dict[Tuple[TunableValue, ...], None]]: @@ -147,7 +147,7 @@ def suggest(self) -> TunableGroups: self._suggested_configs.add(default_config_values) else: # Select the first item from the pending configs. - if not self._pending_configs and self._iter <= self._max_iter: + if not self._pending_configs and self._iter <= self._max_suggestions: _LOG.info("No more pending configs to suggest. Restarting grid.") self._config_keys, self._pending_configs = self._get_grid() try: @@ -185,7 +185,7 @@ def register( return registered_score def not_converged(self) -> bool: - if self._iter > self._max_iter: + if self._iter > self._max_suggestions: if bool(self._pending_configs): _LOG.warning( "Exceeded max iterations, but still have %d pending configs: %s", diff --git a/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py b/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py index dbfc770243a..649b070123b 100644 --- a/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py +++ b/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py @@ -62,12 +62,13 @@ def __init__( ) ) - # Make sure max_trials >= max_iterations. + # Make sure max_trials >= max_suggestions. if "max_trials" not in self._config: - self._config["max_trials"] = self._max_iter - assert ( - int(self._config["max_trials"]) >= self._max_iter - ), f"max_trials {self._config.get('max_trials')} <= max_iterations {self._max_iter}" + self._config["max_trials"] = self._max_suggestions + assert int(self._config["max_trials"]) >= self._max_suggestions, ( + f"max_trials {self._config.get('max_trials')} " + f"<= max_suggestions{self._max_suggestions}" + ) if "run_name" not in self._config and self.experiment_id: self._config["run_name"] = self.experiment_id diff --git a/mlos_bench/mlos_bench/optimizers/one_shot_optimizer.py b/mlos_bench/mlos_bench/optimizers/one_shot_optimizer.py index f41114c1850..cae735a8496 100644 --- a/mlos_bench/mlos_bench/optimizers/one_shot_optimizer.py +++ b/mlos_bench/mlos_bench/optimizers/one_shot_optimizer.py @@ -32,7 +32,7 @@ def __init__( ): super().__init__(tunables, config, global_config, service) _LOG.info("Run a single iteration for: %s", self._tunables) - self._max_iter = 1 # Always run for just one iteration. + self._max_suggestions = 1 # Always run for just one iteration. def suggest(self) -> TunableGroups: """Always produce the same (initial) suggestion.""" diff --git a/mlos_bench/mlos_bench/tests/launcher_parse_args_test.py b/mlos_bench/mlos_bench/tests/launcher_parse_args_test.py index 2b9c31c014b..ce56ae17a27 100644 --- a/mlos_bench/mlos_bench/tests/launcher_parse_args_test.py +++ b/mlos_bench/mlos_bench/tests/launcher_parse_args_test.py @@ -103,7 +103,7 @@ def test_launcher_args_parse_defaults(config_paths: List[str]) -> None: assert isinstance(launcher.optimizer, OneShotOptimizer) # Check that the optimizer got initialized with defaults. assert launcher.optimizer.tunable_params.is_defaults() - assert launcher.optimizer.max_iterations == 1 # value for OneShotOptimizer + assert launcher.optimizer.max_suggestions == 1 # value for OneShotOptimizer # Check that we pick up the right scheduler config: assert isinstance(launcher.scheduler, SyncScheduler) assert launcher.scheduler.trial_config_repeat_count == 1 # default @@ -155,7 +155,7 @@ def test_launcher_args_parse_1(config_paths: List[str]) -> None: assert isinstance(launcher.optimizer, OneShotOptimizer) # Check that the optimizer got initialized with defaults. assert launcher.optimizer.tunable_params.is_defaults() - assert launcher.optimizer.max_iterations == 1 # value for OneShotOptimizer + assert launcher.optimizer.max_suggestions == 1 # value for OneShotOptimizer # Check that we pick up the right scheduler config: assert isinstance(launcher.scheduler, SyncScheduler) assert ( @@ -223,7 +223,7 @@ def test_launcher_args_parse_2(config_paths: List[str]) -> None: "max_suggestions", opt_config.get("config", {}).get("max_suggestions", 100) ) assert ( - launcher.optimizer.max_iterations + launcher.optimizer.max_suggestions == orig_max_iters == launcher.global_config["max_suggestions"] ) diff --git a/mlos_bench/mlos_bench/tests/optimizers/conftest.py b/mlos_bench/mlos_bench/tests/optimizers/conftest.py index 6b660f7fea2..d4418f58b31 100644 --- a/mlos_bench/mlos_bench/tests/optimizers/conftest.py +++ b/mlos_bench/mlos_bench/tests/optimizers/conftest.py @@ -111,7 +111,7 @@ def flaml_opt_max(tunable_groups: TunableGroups) -> MlosCoreOptimizer: # FIXME: SMAC's RF model can be non-deterministic at low iterations, which are -# normally calculated as a percentage of the max_iterations and number of +# normally calculated as a percentage of the max_suggestions and number of # tunable dimensions, so for now we set the initial random samples equal to the # number of iterations and control them with a seed. diff --git a/mlos_bench/mlos_bench/tests/optimizers/grid_search_optimizer_test.py b/mlos_bench/mlos_bench/tests/optimizers/grid_search_optimizer_test.py index 703381e4882..863fc5831a3 100644 --- a/mlos_bench/mlos_bench/tests/optimizers/grid_search_optimizer_test.py +++ b/mlos_bench/mlos_bench/tests/optimizers/grid_search_optimizer_test.py @@ -83,11 +83,11 @@ def grid_search_opt( assert len(grid_search_tunables) == 3 # Test the convergence logic by controlling the number of iterations to be not a # multiple of the number of elements in the grid. - max_iterations = len(grid_search_tunables_grid) * 2 - 3 + max_suggestions = len(grid_search_tunables_grid) * 2 - 3 return GridSearchOptimizer( tunables=grid_search_tunables, config={ - "max_suggestions": max_iterations, + "max_suggestions": max_suggestions, "optimization_targets": {"score": "max", "other_score": "min"}, }, ) @@ -187,7 +187,7 @@ def test_grid_search( # But if we still have iterations left, we should be able to suggest again by # refilling the grid. - assert grid_search_opt.current_iteration < grid_search_opt.max_iterations + assert grid_search_opt.current_iteration < grid_search_opt.max_suggestions assert grid_search_opt.suggest() assert list(grid_search_opt.pending_configs) assert list(grid_search_opt.suggested_configs) @@ -198,7 +198,7 @@ def test_grid_search( suggestion = grid_search_opt.suggest() grid_search_opt.register(suggestion, status, score) assert not grid_search_opt.not_converged() - assert grid_search_opt.current_iteration >= grid_search_opt.max_iterations + assert grid_search_opt.current_iteration >= grid_search_opt.max_suggestions assert list(grid_search_opt.pending_configs) assert list(grid_search_opt.suggested_configs) From 85fdec3e52394f114edf4898630dd71135577428 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Mon, 19 Aug 2024 18:16:52 -0500 Subject: [PATCH 09/36] Convert - dashes to _ underscores when parsing CLI arg extra globals (#849) See Also: #847 --------- Co-authored-by: Sergiy Matusevych --- mlos_bench/mlos_bench/launcher.py | 4 ++++ mlos_bench/mlos_bench/tests/launcher_in_process_test.py | 2 +- mlos_bench/mlos_bench/tests/launcher_parse_args_test.py | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/mlos_bench/mlos_bench/launcher.py b/mlos_bench/mlos_bench/launcher.py index 9024b15adfd..339a11963de 100644 --- a/mlos_bench/mlos_bench/launcher.py +++ b/mlos_bench/mlos_bench/launcher.py @@ -399,6 +399,10 @@ def _try_parse_extra_args(cmdline: Iterable[str]) -> Dict[str, TunableValue]: # Handles missing trailing elem from last --key arg. raise ValueError("Command line argument has no value: " + key) + # Convert "max-suggestions" to "max_suggestions" for compatibility with + # other CLI options to use as common python/json variable replacements. + config = {k.replace("-", "_"): v for k, v in config.items()} + _LOG.debug("Parsed config: %s", config) return config diff --git a/mlos_bench/mlos_bench/tests/launcher_in_process_test.py b/mlos_bench/mlos_bench/tests/launcher_in_process_test.py index 6fe340c9eb5..eb20b1ababd 100644 --- a/mlos_bench/mlos_bench/tests/launcher_in_process_test.py +++ b/mlos_bench/mlos_bench/tests/launcher_in_process_test.py @@ -31,7 +31,7 @@ "mlos_bench/mlos_bench/tests/config/cli/mock-opt.jsonc", "--trial_config_repeat_count", "3", - "--max_suggestions", + "--max-suggestions", "3", "--mock_env_seed", "42", # Noisy Mock Environment. diff --git a/mlos_bench/mlos_bench/tests/launcher_parse_args_test.py b/mlos_bench/mlos_bench/tests/launcher_parse_args_test.py index ce56ae17a27..118fa13ba97 100644 --- a/mlos_bench/mlos_bench/tests/launcher_parse_args_test.py +++ b/mlos_bench/mlos_bench/tests/launcher_parse_args_test.py @@ -260,9 +260,12 @@ def test_launcher_args_parse_3(config_paths: List[str]) -> None: " ".join([f"--config-path {config_path}" for config_path in config_paths]) + f" --config {config_file}" + f" --globals {globals_file}" + + " --max-suggestions 10" # check for - to _ conversion too ) launcher = _get_launcher(__name__, cli_args) + assert launcher.optimizer.max_suggestions == 10 # from CLI args + # Check that CLI file parameter overrides JSON config: assert isinstance(launcher.scheduler, SyncScheduler) # from test-cli-config.jsonc (should override scheduler config file) From e514908d133161934b76663da7b6753adc07b771 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Tue, 20 Aug 2024 12:33:59 -0500 Subject: [PATCH 10/36] Basic error checks and exit zero on success (#850) The `mlos_bench` CLI wrapper exits non-zero currently even on success. This PR adds some basic sanity checks and makes sure we exit 0 when the process looks roughly OK. Further work to be expanded in #523. --------- Co-authored-by: Sergiy Matusevych Co-authored-by: Sergiy Matusevych --- mlos_bench/mlos_bench/run.py | 55 ++++++++++++++++++- .../mlos_bench/schedulers/base_scheduler.py | 14 ++++- mlos_bench/mlos_bench/storage/base_storage.py | 7 +++ mlos_bench/pyproject.toml | 2 +- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/mlos_bench/mlos_bench/run.py b/mlos_bench/mlos_bench/run.py index 57c48a87b9e..a554f1a803d 100755 --- a/mlos_bench/mlos_bench/run.py +++ b/mlos_bench/mlos_bench/run.py @@ -12,29 +12,80 @@ """ import logging +import sys from typing import Dict, List, Optional, Tuple +import numpy as np + from mlos_bench.launcher import Launcher from mlos_bench.tunables.tunable_groups import TunableGroups _LOG = logging.getLogger(__name__) +def _sanity_check_results(launcher: Launcher) -> None: + """Do some sanity checking on the results and throw an exception if it looks like + something went wrong. + """ + basic_err_msg = "Check configuration, scripts, and logs for details." + + # Check if the scheduler has any trials. + if not launcher.scheduler.trial_count: + raise RuntimeError(f"No trials were run. {basic_err_msg}") + + # Check if the scheduler ran the expected number of trials. + expected_trial_count = min( + launcher.scheduler.max_trials if launcher.scheduler.max_trials > 0 else np.inf, + launcher.scheduler.trial_config_repeat_count * launcher.optimizer.max_suggestions, + ) + if launcher.scheduler.trial_count < expected_trial_count: + raise RuntimeError( + f"Expected {expected_trial_count} trials, " + f"but only {launcher.scheduler.trial_count} were run. {basic_err_msg}" + ) + + # Check to see if "too many" trials seem to have failed (#523). + unsuccessful_trials = [t for t in launcher.scheduler.ran_trials if not t.status.is_succeeded()] + if len(unsuccessful_trials) > 0.2 * launcher.scheduler.trial_count: + raise RuntimeWarning( + "Too many trials failed: " + f"{len(unsuccessful_trials)} out of {launcher.scheduler.trial_count}. " + f"{basic_err_msg}" + ) + + def _main( argv: Optional[List[str]] = None, ) -> Tuple[Optional[Dict[str, float]], Optional[TunableGroups]]: - launcher = Launcher("mlos_bench", "Systems autotuning and benchmarking tool", argv=argv) with launcher.scheduler as scheduler_context: scheduler_context.start() scheduler_context.teardown() + _sanity_check_results(launcher) + (score, _config) = result = launcher.scheduler.get_best_observation() # NOTE: This log line is used in test_launch_main_app_* unit tests: _LOG.info("Final score: %s", score) return result +def _shell_main( + argv: Optional[List[str]] = None, +) -> int: + (best_score, best_config) = _main(argv) + # Exit zero if it looks like the overall operation was successful. + # TODO: Improve this sanity check to be more robust. + if ( + best_score + and best_config + and all(isinstance(score_value, float) for score_value in best_score.values()) + ): + return 0 + else: + raise ValueError(f"Unexpected result: {best_score=}, {best_config=}") + + if __name__ == "__main__": - _main() + sys.exit(_shell_main()) diff --git a/mlos_bench/mlos_bench/schedulers/base_scheduler.py b/mlos_bench/mlos_bench/schedulers/base_scheduler.py index 30805c189e0..f38e51e7133 100644 --- a/mlos_bench/mlos_bench/schedulers/base_scheduler.py +++ b/mlos_bench/mlos_bench/schedulers/base_scheduler.py @@ -9,7 +9,7 @@ from abc import ABCMeta, abstractmethod from datetime import datetime from types import TracebackType -from typing import Any, Dict, Optional, Tuple, Type +from typing import Any, Dict, List, Optional, Tuple, Type from pytz import UTC from typing_extensions import Literal @@ -87,6 +87,7 @@ def __init__( # pylint: disable=too-many-arguments self.storage = storage self._root_env_config = root_env_config self._last_trial_id = -1 + self._ran_trials: List[Storage.Trial] = [] _LOG.debug("Scheduler instantiated: %s :: %s", self, config) @@ -113,6 +114,11 @@ def trial_config_repeat_count(self) -> int: """Gets the number of trials to run for a given config.""" return self._trial_config_repeat_count + @property + def trial_count(self) -> int: + """Gets the current number of trials run for the experiment.""" + return self._trial_count + @property def max_trials(self) -> int: """Gets the maximum number of trials to run for a given experiment, or -1 for no @@ -302,4 +308,10 @@ def run_trial(self, trial: Storage.Trial) -> None: """ assert self.experiment is not None self._trial_count += 1 + self._ran_trials.append(trial) _LOG.info("QUEUE: Execute trial # %d/%d :: %s", self._trial_count, self._max_trials, trial) + + @property + def ran_trials(self) -> List[Storage.Trial]: + """Get the list of trials that were run.""" + return self._ran_trials diff --git a/mlos_bench/mlos_bench/storage/base_storage.py b/mlos_bench/mlos_bench/storage/base_storage.py index 79e220c360c..d3e9b6583d5 100644 --- a/mlos_bench/mlos_bench/storage/base_storage.py +++ b/mlos_bench/mlos_bench/storage/base_storage.py @@ -391,6 +391,7 @@ def __init__( # pylint: disable=too-many-arguments self._tunable_config_id = tunable_config_id self._opt_targets = opt_targets self._config = config or {} + self._status = Status.UNKNOWN def __repr__(self) -> str: return f"{self._experiment_id}:{self._trial_id}:{self._tunable_config_id}" @@ -435,6 +436,11 @@ def config(self, global_config: Optional[Dict[str, Any]] = None) -> Dict[str, An config["trial_id"] = self._trial_id return config + @property + def status(self) -> Status: + """Get the status of the current trial.""" + return self._status + @abstractmethod def update( self, @@ -471,6 +477,7 @@ def update( opt_targets.difference(metrics.keys()), ) # raise ValueError() + self._status = status return metrics @abstractmethod diff --git a/mlos_bench/pyproject.toml b/mlos_bench/pyproject.toml index 199af4c4c22..72bc0263077 100644 --- a/mlos_bench/pyproject.toml +++ b/mlos_bench/pyproject.toml @@ -41,7 +41,7 @@ dynamic = [ ] [project.scripts] -mlos_bench = "mlos_bench.run:_main" +mlos_bench = "mlos_bench.run:_shell_main" [project.urls] Documentation = "https://microsoft.github.io/MLOS/source_tree_docs/mlos_bench/" From 2b2a9f0e7312db42643934e4ca666786ce915011 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Wed, 21 Aug 2024 18:01:46 -0500 Subject: [PATCH 11/36] Move quantization_bins into meta field of NumericalHyperaparameter (#851) Co-authored-by: Sergiy Matusevych --- .../optimizers/convert_configspace.py | 40 +++--- .../tunables/tunable_to_configspace_test.py | 60 +++++---- mlos_core/mlos_core/spaces/converters/util.py | 78 +++++++++--- .../tests/spaces/adapters/llamatune_test.py | 77 +++++++----- .../spaces/monkey_patch_quantization_test.py | 115 ++++++++++++++++-- 5 files changed, 266 insertions(+), 104 deletions(-) diff --git a/mlos_bench/mlos_bench/optimizers/convert_configspace.py b/mlos_bench/mlos_bench/optimizers/convert_configspace.py index 4d3deb59269..755918fa99d 100644 --- a/mlos_bench/mlos_bench/optimizers/convert_configspace.py +++ b/mlos_bench/mlos_bench/optimizers/convert_configspace.py @@ -26,7 +26,10 @@ from mlos_bench.tunables.tunable import Tunable, TunableValue from mlos_bench.tunables.tunable_groups import TunableGroups from mlos_bench.util import try_parse_val -from mlos_core.spaces.converters.util import monkey_patch_quantization +from mlos_core.spaces.converters.util import ( + QUANTIZATION_BINS_META_KEY, + monkey_patch_hp_quantization, +) _LOG = logging.getLogger(__name__) @@ -77,6 +80,10 @@ def _tunable_to_configspace( meta: Dict[Hashable, TunableValue] = {"cost": cost} if group_name is not None: meta["group"] = group_name + if tunable.is_numerical and tunable.quantization_bins: + # Temporary workaround to dropped quantization support in ConfigSpace 1.0 + # See Also: https://github.com/automl/ConfigSpace/issues/390 + meta[QUANTIZATION_BINS_META_KEY] = tunable.quantization_bins if tunable.type == "categorical": return ConfigurationSpace( @@ -137,13 +144,9 @@ def _tunable_to_configspace( else: raise TypeError(f"Invalid Parameter Type: {tunable.type}") - if tunable.quantization_bins: - # Temporary workaround to dropped quantization support in ConfigSpace 1.0 - # See Also: https://github.com/automl/ConfigSpace/issues/390 - monkey_patch_quantization(range_hp, tunable.quantization_bins) - + monkey_patch_hp_quantization(range_hp) if not tunable.special: - return ConfigurationSpace({tunable.name: range_hp}) + return ConfigurationSpace(space=[range_hp]) # Compute the probabilities of switching between regular and special values. special_weights: Optional[List[float]] = None @@ -156,30 +159,33 @@ def _tunable_to_configspace( # one for special values, and one to choose between the two. (special_name, type_name) = special_param_names(tunable.name) conf_space = ConfigurationSpace( - { - tunable.name: range_hp, - special_name: CategoricalHyperparameter( + space=[ + range_hp, + CategoricalHyperparameter( name=special_name, choices=tunable.special, weights=special_weights, default_value=tunable.default if tunable.default in tunable.special else NotSet, meta=meta, ), - type_name: CategoricalHyperparameter( + CategoricalHyperparameter( name=type_name, choices=[TunableValueKind.SPECIAL, TunableValueKind.RANGE], weights=switch_weights, default_value=TunableValueKind.SPECIAL, ), - } + ] ) conf_space.add( - EqualsCondition(conf_space[special_name], conf_space[type_name], TunableValueKind.SPECIAL) - ) - conf_space.add( - EqualsCondition(conf_space[tunable.name], conf_space[type_name], TunableValueKind.RANGE) + [ + EqualsCondition( + conf_space[special_name], conf_space[type_name], TunableValueKind.SPECIAL + ), + EqualsCondition( + conf_space[tunable.name], conf_space[type_name], TunableValueKind.RANGE + ), + ] ) - return conf_space diff --git a/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_test.py b/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_test.py index e33ee3fc9c3..55bc1301226 100644 --- a/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_test.py +++ b/mlos_bench/mlos_bench/tests/tunables/tunable_to_configspace_test.py @@ -13,7 +13,6 @@ UniformFloatHyperparameter, UniformIntegerHyperparameter, ) -from ConfigSpace.hyperparameters import NumericalHyperparameter from mlos_bench.optimizers.convert_configspace import ( TunableValueKind, @@ -23,7 +22,10 @@ ) from mlos_bench.tunables.tunable import Tunable from mlos_bench.tunables.tunable_groups import TunableGroups -from mlos_core.spaces.converters.util import monkey_patch_quantization +from mlos_core.spaces.converters.util import ( + QUANTIZATION_BINS_META_KEY, + monkey_patch_cs_quantization, +) # pylint: disable=redefined-outer-name @@ -45,66 +47,67 @@ def configuration_space() -> ConfigurationSpace: # NOTE: FLAML requires distribution to be uniform spaces = ConfigurationSpace( - { - "vmSize": CategoricalHyperparameter( + space=[ + CategoricalHyperparameter( name="vmSize", choices=["Standard_B2s", "Standard_B2ms", "Standard_B4ms"], default_value="Standard_B4ms", meta={"group": "provision", "cost": 0}, ), - "idle": CategoricalHyperparameter( + CategoricalHyperparameter( name="idle", choices=["halt", "mwait", "noidle"], default_value="halt", meta={"group": "boot", "cost": 0}, ), - "kernel_sched_latency_ns": Integer( + Integer( name="kernel_sched_latency_ns", bounds=(0, 1000000000), log=False, default=2000000, - meta={"group": "kernel", "cost": 0}, + meta={ + "group": "kernel", + "cost": 0, + QUANTIZATION_BINS_META_KEY: 11, + }, ), - "kernel_sched_migration_cost_ns": Integer( + Integer( name="kernel_sched_migration_cost_ns", bounds=(0, 500000), log=False, default=250000, meta={"group": "kernel", "cost": 0}, ), - kernel_sched_migration_cost_ns_special: CategoricalHyperparameter( + CategoricalHyperparameter( name=kernel_sched_migration_cost_ns_special, choices=[-1, 0], weights=[0.5, 0.5], default_value=-1, meta={"group": "kernel", "cost": 0}, ), - kernel_sched_migration_cost_ns_type: CategoricalHyperparameter( + CategoricalHyperparameter( name=kernel_sched_migration_cost_ns_type, choices=[TunableValueKind.SPECIAL, TunableValueKind.RANGE], weights=[0.5, 0.5], default_value=TunableValueKind.SPECIAL, ), - } - ) - spaces.add( - EqualsCondition( - spaces[kernel_sched_migration_cost_ns_special], - spaces[kernel_sched_migration_cost_ns_type], - TunableValueKind.SPECIAL, - ) + ] ) spaces.add( - EqualsCondition( - spaces["kernel_sched_migration_cost_ns"], - spaces[kernel_sched_migration_cost_ns_type], - TunableValueKind.RANGE, - ) + [ + EqualsCondition( + spaces[kernel_sched_migration_cost_ns_special], + spaces[kernel_sched_migration_cost_ns_type], + TunableValueKind.SPECIAL, + ), + EqualsCondition( + spaces["kernel_sched_migration_cost_ns"], + spaces[kernel_sched_migration_cost_ns_type], + TunableValueKind.RANGE, + ), + ] ) - hp = spaces["kernel_sched_latency_ns"] - assert isinstance(hp, NumericalHyperparameter) - monkey_patch_quantization(hp, quantization_bins=11) - return spaces + return monkey_patch_cs_quantization(spaces) def _cmp_tunable_hyperparameter_categorical(tunable: Tunable, space: ConfigurationSpace) -> None: @@ -122,6 +125,9 @@ def _cmp_tunable_hyperparameter_numerical(tunable: Tunable, space: Configuration assert (param.lower, param.upper) == tuple(tunable.range) if tunable.in_range(tunable.value): assert param.default_value == tunable.value + assert (param.meta or {}).get(QUANTIZATION_BINS_META_KEY) == tunable.quantization_bins + if tunable.quantization_bins: + assert param.sample_value() in list(tunable.quantized_values or []) def test_tunable_to_configspace_categorical(tunable_categorical: Tunable) -> None: diff --git a/mlos_core/mlos_core/spaces/converters/util.py b/mlos_core/mlos_core/spaces/converters/util.py index 23eb223584f..4393890595e 100644 --- a/mlos_core/mlos_core/spaces/converters/util.py +++ b/mlos_core/mlos_core/spaces/converters/util.py @@ -4,36 +4,82 @@ # """Helper functions for config space converters.""" +from ConfigSpace import ConfigurationSpace from ConfigSpace.functional import quantize -from ConfigSpace.hyperparameters import NumericalHyperparameter +from ConfigSpace.hyperparameters import Hyperparameter, NumericalHyperparameter +QUANTIZATION_BINS_META_KEY = "quantization_bins" -def monkey_patch_quantization(hp: NumericalHyperparameter, quantization_bins: int) -> None: + +def monkey_patch_hp_quantization(hp: Hyperparameter) -> Hyperparameter: """ Monkey-patch quantization into the Hyperparameter. + Temporary workaround to dropped quantization support in ConfigSpace 1.0 + See Also: + Parameters ---------- - hp : NumericalHyperparameter + hp : Hyperparameter ConfigSpace hyperparameter to patch. - quantization_bins : int - Number of bins to quantize the hyperparameter into. + + Returns + ------- + hp : Hyperparameter + Patched hyperparameter. """ + if not isinstance(hp, NumericalHyperparameter): + return hp + + assert isinstance(hp, NumericalHyperparameter) + dist = hp._vector_dist # pylint: disable=protected-access + quantization_bins = (hp.meta or {}).get(QUANTIZATION_BINS_META_KEY) + if quantization_bins is None: + # No quantization requested. + # Remove any previously applied patches. + if hasattr(dist, "sample_vector_mlos_orig"): + setattr(dist, "sample_vector", dist.sample_vector_mlos_orig) + delattr(dist, "sample_vector_mlos_orig") + return hp + + try: + quantization_bins = int(quantization_bins) + except ValueError as ex: + raise ValueError(f"{quantization_bins=} :: must be an integer.") from ex + if quantization_bins <= 1: raise ValueError(f"{quantization_bins=} :: must be greater than 1.") - # Temporary workaround to dropped quantization support in ConfigSpace 1.0 - # See Also: https://github.com/automl/ConfigSpace/issues/390 - if not hasattr(hp, "sample_value_mlos_orig"): - setattr(hp, "sample_value_mlos_orig", hp.sample_value) + if not hasattr(dist, "sample_vector_mlos_orig"): + setattr(dist, "sample_vector_mlos_orig", dist.sample_vector) - assert hasattr(hp, "sample_value_mlos_orig") + assert hasattr(dist, "sample_vector_mlos_orig") setattr( - hp, - "sample_value", - lambda size=None, **kwargs: quantize( - hp.sample_value_mlos_orig(size, **kwargs), - bounds=(hp.lower, hp.upper), + dist, + "sample_vector", + lambda n, *, seed=None: quantize( + dist.sample_vector_mlos_orig(n, seed=seed), + bounds=(dist.lower_vectorized, dist.upper_vectorized), bins=quantization_bins, - ).astype(type(hp.default_value)), + ), ) + return hp + + +def monkey_patch_cs_quantization(cs: ConfigurationSpace) -> ConfigurationSpace: + """ + Monkey-patch quantization into the Hyperparameters of a ConfigSpace. + + Parameters + ---------- + cs : ConfigurationSpace + ConfigSpace to patch. + + Returns + ------- + cs : ConfigurationSpace + Patched ConfigSpace. + """ + for hp in cs.values(): + monkey_patch_hp_quantization(hp) + return cs diff --git a/mlos_core/mlos_core/tests/spaces/adapters/llamatune_test.py b/mlos_core/mlos_core/tests/spaces/adapters/llamatune_test.py index f9718dc00cc..7ca3c0d6eca 100644 --- a/mlos_core/mlos_core/tests/spaces/adapters/llamatune_test.py +++ b/mlos_core/mlos_core/tests/spaces/adapters/llamatune_test.py @@ -13,7 +13,10 @@ import pytest from mlos_core.spaces.adapters import LlamaTuneAdapter -from mlos_core.spaces.converters.util import monkey_patch_quantization +from mlos_core.spaces.converters.util import ( + QUANTIZATION_BINS_META_KEY, + monkey_patch_cs_quantization, +) # Explicitly test quantized values with llamatune space adapter. # TODO: Add log scale sampling tests as well. @@ -29,28 +32,38 @@ def construct_parameter_space( # pylint: disable=too-many-arguments seed: int = 1234, ) -> CS.ConfigurationSpace: """Helper function for construct an instance of `ConfigSpace.ConfigurationSpace`.""" - input_space = CS.ConfigurationSpace(seed=seed) - - for idx in range(n_continuous_params): - input_space.add(CS.UniformFloatHyperparameter(name=f"cont_{idx}", lower=0, upper=64)) - for idx in range(n_quantized_continuous_params): - param_int = CS.UniformFloatHyperparameter(name=f"cont_{idx}", lower=0, upper=64) - monkey_patch_quantization(param_int, 6) - input_space.add(param_int) - for idx in range(n_integer_params): - input_space.add(CS.UniformIntegerHyperparameter(name=f"int_{idx}", lower=-1, upper=256)) - for idx in range(n_quantized_integer_params): - param_float = CS.UniformIntegerHyperparameter(name=f"int_{idx}", lower=0, upper=256) - monkey_patch_quantization(param_float, 17) - input_space.add(param_float) - for idx in range(n_categorical_params): - input_space.add( - CS.CategoricalHyperparameter( - name=f"str_{idx}", choices=[f"option_{idx}" for idx in range(5)] - ) - ) - - return input_space + input_space = CS.ConfigurationSpace( + seed=seed, + space=[ + *( + CS.UniformFloatHyperparameter(name=f"cont_{idx}", lower=0, upper=64) + for idx in range(n_continuous_params) + ), + *( + CS.UniformFloatHyperparameter( + name=f"cont_{idx}", lower=0, upper=64, meta={QUANTIZATION_BINS_META_KEY: 6} + ) + for idx in range(n_quantized_continuous_params) + ), + *( + CS.UniformIntegerHyperparameter(name=f"int_{idx}", lower=-1, upper=256) + for idx in range(n_integer_params) + ), + *( + CS.UniformIntegerHyperparameter( + name=f"int_{idx}", lower=0, upper=256, meta={QUANTIZATION_BINS_META_KEY: 17} + ) + for idx in range(n_quantized_integer_params) + ), + *( + CS.CategoricalHyperparameter( + name=f"str_{idx}", choices=[f"option_{idx}" for idx in range(5)] + ) + for idx in range(n_categorical_params) + ), + ], + ) + return monkey_patch_cs_quantization(input_space) @pytest.mark.parametrize( @@ -322,15 +335,15 @@ def test_special_parameter_values_biasing() -> None: # pylint: disable=too-comp special_values_instances["int_2"][100] += 1 assert (1 - eps) * int(num_configs * bias_percentage) <= special_values_instances["int_1"][0] - assert (1 - eps) * int(num_configs * bias_percentage / 2) <= special_values_instances["int_1"][ - 1 - ] - assert (1 - eps) * int(num_configs * bias_percentage / 2) <= special_values_instances["int_2"][ - 2 - ] - assert (1 - eps) * int(num_configs * bias_percentage * 1.5) <= special_values_instances[ - "int_2" - ][100] + assert (1 - eps) * int(num_configs * bias_percentage / 2) <= ( + special_values_instances["int_1"][1] + ) + assert (1 - eps) * int(num_configs * bias_percentage / 2) <= ( + special_values_instances["int_2"][2] + ) + assert (1 - eps) * int(num_configs * bias_percentage * 1.5) <= ( + special_values_instances["int_2"][100] + ) def test_max_unique_values_per_param() -> None: diff --git a/mlos_core/mlos_core/tests/spaces/monkey_patch_quantization_test.py b/mlos_core/mlos_core/tests/spaces/monkey_patch_quantization_test.py index d50fe7374e6..dd446d4f5b9 100644 --- a/mlos_core/mlos_core/tests/spaces/monkey_patch_quantization_test.py +++ b/mlos_core/mlos_core/tests/spaces/monkey_patch_quantization_test.py @@ -5,22 +5,37 @@ """Unit tests for ConfigSpace quantization monkey patching.""" import numpy as np -from ConfigSpace import UniformFloatHyperparameter, UniformIntegerHyperparameter +from ConfigSpace import ( + ConfigurationSpace, + UniformFloatHyperparameter, + UniformIntegerHyperparameter, +) from numpy.random import RandomState -from mlos_core.spaces.converters.util import monkey_patch_quantization +from mlos_core.spaces.converters.util import ( + QUANTIZATION_BINS_META_KEY, + monkey_patch_cs_quantization, + monkey_patch_hp_quantization, +) from mlos_core.tests import SEED def test_configspace_quant_int() -> None: """Check the quantization of an integer hyperparameter.""" + quantization_bins = 11 quantized_values = set(range(0, 101, 10)) - hp = UniformIntegerHyperparameter("hp", lower=0, upper=100, log=False) + hp = UniformIntegerHyperparameter( + "hp", + lower=0, + upper=100, + log=False, + meta={QUANTIZATION_BINS_META_KEY: quantization_bins}, + ) # Before patching: expect that at least one value is not quantized. assert not set(hp.sample_value(100)).issubset(quantized_values) - monkey_patch_quantization(hp, 11) + monkey_patch_hp_quantization(hp) # After patching: *all* values must belong to the set of quantized values. assert hp.sample_value() in quantized_values # check scalar type assert set(hp.sample_value(100)).issubset(quantized_values) # batch version @@ -28,14 +43,21 @@ def test_configspace_quant_int() -> None: def test_configspace_quant_float() -> None: """Check the quantization of a float hyperparameter.""" - quantized_values = set(np.linspace(0, 1, num=5, endpoint=True)) - hp = UniformFloatHyperparameter("hp", lower=0, upper=1, log=False) + # 5 is a nice number of bins to avoid floating point errors. + quantization_bins = 5 + quantized_values = set(np.linspace(0, 1, num=quantization_bins, endpoint=True)) + hp = UniformFloatHyperparameter( + "hp", + lower=0, + upper=1, + log=False, + meta={QUANTIZATION_BINS_META_KEY: quantization_bins}, + ) # Before patching: expect that at least one value is not quantized. assert not set(hp.sample_value(100)).issubset(quantized_values) - # 5 is a nice number of bins to avoid floating point errors. - monkey_patch_quantization(hp, 5) + monkey_patch_hp_quantization(hp) # After patching: *all* values must belong to the set of quantized values. assert hp.sample_value() in quantized_values # check scalar type assert set(hp.sample_value(100)).issubset(quantized_values) # batch version @@ -43,25 +65,94 @@ def test_configspace_quant_float() -> None: def test_configspace_quant_repatch() -> None: """Repatch the same hyperparameter with different number of bins.""" + quantization_bins = 11 quantized_values = set(range(0, 101, 10)) - hp = UniformIntegerHyperparameter("hp", lower=0, upper=100, log=False) + hp = UniformIntegerHyperparameter( + "hp", + lower=0, + upper=100, + log=False, + meta={QUANTIZATION_BINS_META_KEY: quantization_bins}, + ) # Before patching: expect that at least one value is not quantized. assert not set(hp.sample_value(100)).issubset(quantized_values) - monkey_patch_quantization(hp, 11) + monkey_patch_hp_quantization(hp) # After patching: *all* values must belong to the set of quantized values. samples = hp.sample_value(100, seed=RandomState(SEED)) assert set(samples).issubset(quantized_values) # Patch the same hyperparameter again and check that the results are the same. - monkey_patch_quantization(hp, 11) + monkey_patch_hp_quantization(hp) # After patching: *all* values must belong to the set of quantized values. assert all(samples == hp.sample_value(100, seed=RandomState(SEED))) # Repatch with the higher number of bins and make sure we get new values. - monkey_patch_quantization(hp, 21) + new_meta = dict(hp.meta or {}) + new_meta[QUANTIZATION_BINS_META_KEY] = 21 + hp.meta = new_meta + monkey_patch_hp_quantization(hp) samples_set = set(hp.sample_value(100, seed=RandomState(SEED))) quantized_values_new = set(range(5, 96, 10)) assert samples_set.issubset(set(range(0, 101, 5))) assert len(samples_set - quantized_values_new) < len(samples_set) + + # Repatch without quantization and make sure we get the original values. + new_meta = dict(hp.meta or {}) + del new_meta[QUANTIZATION_BINS_META_KEY] + hp.meta = new_meta + assert hp.meta.get(QUANTIZATION_BINS_META_KEY) is None + monkey_patch_hp_quantization(hp) + samples_set = set(hp.sample_value(100, seed=RandomState(SEED))) + assert samples_set.issubset(set(range(0, 101))) + assert len(quantized_values_new) < len(quantized_values) < len(samples_set) + + +def test_configspace_quant() -> None: + """Test quantization of multiple hyperparameters in the ConfigSpace.""" + space = ConfigurationSpace( + name="cs_test", + space={ + "hp_int": (0, 100000), + "hp_int_quant": (0, 100000), + "hp_float": (0.0, 1.0), + "hp_categorical": ["a", "b", "c"], + "hp_constant": 1337, + }, + ) + space["hp_int_quant"].meta = {QUANTIZATION_BINS_META_KEY: 5} + space["hp_float"].meta = {QUANTIZATION_BINS_META_KEY: 11} + monkey_patch_cs_quantization(space) + + space.seed(SEED) + assert dict(space.sample_configuration()) == { + "hp_categorical": "c", + "hp_constant": 1337, + "hp_float": 0.6, + "hp_int": 60263, + "hp_int_quant": 0, + } + assert [dict(conf) for conf in space.sample_configuration(3)] == [ + { + "hp_categorical": "a", + "hp_constant": 1337, + "hp_float": 0.4, + "hp_int": 59150, + "hp_int_quant": 50000, + }, + { + "hp_categorical": "a", + "hp_constant": 1337, + "hp_float": 0.3, + "hp_int": 65725, + "hp_int_quant": 75000, + }, + { + "hp_categorical": "b", + "hp_constant": 1337, + "hp_float": 0.6, + "hp_int": 84654, + "hp_int_quant": 25000, + }, + ] From ff349f7db9f0bed8bf2021cf75ae7a71c1cba2e8 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Tue, 17 Sep 2024 14:46:09 -0500 Subject: [PATCH 12/36] Fixup: use corrected python build package name from conda forge (#856) The [`build`](https://anaconda.org/conda-forge/build) name in conda-forge apparently hasn't been updated in 3 years. Whereas [`python-build`](https://anaconda.org/conda-forge/python-build) is updated frequently. Probably this is for namespacing reasons. --- .github/workflows/devcontainer.yml | 7 +++++++ conda-envs/mlos-3.10.yml | 2 +- conda-envs/mlos-3.11.yml | 2 +- conda-envs/mlos-3.12.yml | 2 +- conda-envs/mlos-3.8.yml | 2 +- conda-envs/mlos-3.9.yml | 2 +- conda-envs/mlos-windows.yml | 2 +- conda-envs/mlos.yml | 2 +- 8 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index b57257a9075..82d230543d5 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -138,6 +138,13 @@ jobs: set -x docker exec --user vscode --env USER=vscode mlos-devcontainer make CONDA_INFO_LEVEL=-v conda-env + - name: Verify expected version of python in conda env + timeout-minutes: 2 + run: | + set -x + docker exec --user vscode --env USER=vscode mlos-devcontainer conda run -n mlos python -c \ + 'from sys import version_info as vers; assert (vers.major, vers.minor) == (3, 12), f"Unexpected python version: {vers}"' + - name: Check for missing licenseheaders timeout-minutes: 3 run: | diff --git a/conda-envs/mlos-3.10.yml b/conda-envs/mlos-3.10.yml index ce55fde25c1..f4c63f118a6 100644 --- a/conda-envs/mlos-3.10.yml +++ b/conda-envs/mlos-3.10.yml @@ -11,7 +11,7 @@ dependencies: - pycodestyle - pydocstyle - flake8 - - build + - python-build - jupyter - ipykernel - nb_conda_kernels diff --git a/conda-envs/mlos-3.11.yml b/conda-envs/mlos-3.11.yml index 40d28e3980d..8114fdca644 100644 --- a/conda-envs/mlos-3.11.yml +++ b/conda-envs/mlos-3.11.yml @@ -11,7 +11,7 @@ dependencies: - pycodestyle - pydocstyle - flake8 - - build + - python-build - jupyter - ipykernel - nb_conda_kernels diff --git a/conda-envs/mlos-3.12.yml b/conda-envs/mlos-3.12.yml index ef76140c045..7255766b1b4 100644 --- a/conda-envs/mlos-3.12.yml +++ b/conda-envs/mlos-3.12.yml @@ -13,7 +13,7 @@ dependencies: - pycodestyle - pydocstyle - flake8 - - build + - python-build - jupyter - ipykernel - nb_conda_kernels diff --git a/conda-envs/mlos-3.8.yml b/conda-envs/mlos-3.8.yml index 379cebe7b31..756f7908582 100644 --- a/conda-envs/mlos-3.8.yml +++ b/conda-envs/mlos-3.8.yml @@ -11,7 +11,7 @@ dependencies: - pycodestyle - pydocstyle - flake8 - - build + - python-build - jupyter - ipykernel - nb_conda_kernels diff --git a/conda-envs/mlos-3.9.yml b/conda-envs/mlos-3.9.yml index b29d3e53f5d..3aab626ce10 100644 --- a/conda-envs/mlos-3.9.yml +++ b/conda-envs/mlos-3.9.yml @@ -11,7 +11,7 @@ dependencies: - pycodestyle - pydocstyle - flake8 - - build + - python-build - jupyter - ipykernel - nb_conda_kernels diff --git a/conda-envs/mlos-windows.yml b/conda-envs/mlos-windows.yml index 1a40a5738ec..e27e5b42e09 100644 --- a/conda-envs/mlos-windows.yml +++ b/conda-envs/mlos-windows.yml @@ -12,7 +12,7 @@ dependencies: - pycodestyle - pydocstyle - flake8 - - build + - python-build - jupyter - ipykernel - nb_conda_kernels diff --git a/conda-envs/mlos.yml b/conda-envs/mlos.yml index f242dc2226a..34d1db429df 100644 --- a/conda-envs/mlos.yml +++ b/conda-envs/mlos.yml @@ -11,7 +11,7 @@ dependencies: - pycodestyle - pydocstyle - flake8 - - build + - python-build - jupyter - ipykernel - nb_conda_kernels From 0e8ef2cf624559daa46609aebf6ee7a224da9518 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Mon, 23 Sep 2024 13:48:41 -0500 Subject: [PATCH 13/36] Backporting small tweaks from the demo repo(s) (#842) --- .devcontainer/build/build-devcontainer.sh | 15 ++++++++++----- .devcontainer/devcontainer.json | 3 ++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.devcontainer/build/build-devcontainer.sh b/.devcontainer/build/build-devcontainer.sh index b6000702595..5aba0ac64fd 100755 --- a/.devcontainer/build/build-devcontainer.sh +++ b/.devcontainer/build/build-devcontainer.sh @@ -8,8 +8,13 @@ set -x set -eu scriptdir=$(dirname "$(readlink -f "$0")") +repo_root=$(readlink -f "$scriptdir/../..") +repo_name=$(basename "$repo_root") cd "$scriptdir/" +DEVCONTAINER_IMAGE="devcontainer-cli:latest" +MLOS_AUTOTUNING_IMAGE="mlos-devcontainer:latest" + # Build the helper container that has the devcontainer CLI for building the devcontainer. NO_CACHE=${NO_CACHE:-} ./build-devcontainer-cli.sh @@ -20,13 +25,13 @@ if [ -w /var/run/docker-host.sock ]; then fi # Build the devcontainer image. -rootdir=$(readlink -f "$scriptdir/../..") +rootdir="$repo_root" # Run the initialize command on the host first. # Note: command should already pull the cached image if possible. pwd -devcontainer_json=$(cat "$rootdir/.devcontainer/devcontainer.json" | sed -e 's|//.*||' -e 's|/\*|\n&|g;s|*/|&\n|g' | sed -e '/\/\*/,/*\//d') -initializeCommand=$(echo "$devcontainer_json" | docker run -i --rm devcontainer-cli jq -e -r '.initializeCommand[]') +devcontainer_json=$(cat "$rootdir/.devcontainer/devcontainer.json" | sed -e 's|^\s*//.*||' -e 's|/\*|\n&|g;s|*/|&\n|g' | sed -e '/\/\*/,/*\//d') +initializeCommand=$(echo "$devcontainer_json" | docker run -i --rm $DEVCONTAINER_IMAGE jq -e -r '.initializeCommand[]') if [ -z "$initializeCommand" ]; then echo "No initializeCommand found in devcontainer.json" >&2 exit 1 @@ -61,10 +66,10 @@ docker run -i --rm \ --env http_proxy=${http_proxy:-} \ --env https_proxy=${https_proxy:-} \ --env no_proxy=${no_proxy:-} \ - devcontainer-cli \ + $DEVCONTAINER_IMAGE \ devcontainer build --workspace-folder /src \ $devcontainer_build_args \ - --image-name mlos-devcontainer:latest + --image-name $MLOS_AUTOTUNING_IMAGE if [ "${CONTAINER_REGISTRY:-}" != '' ]; then docker tag mlos-devcontainer:latest "$CONTAINER_REGISTRY/mlos-devcontainer:latest" fi diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1f56be9d1a0..1a02d31803d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -82,7 +82,8 @@ "streetsidesoftware.code-spell-checker", "tamasfe.even-better-toml", "trond-snekvik.simple-rst", - "tyriar.sort-lines" + "tyriar.sort-lines", + "ms-toolsai.jupyter" ] } } From fcc49aab5ed7cd37fe983d0283370c0829a7a20b Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Tue, 24 Sep 2024 15:11:56 -0500 Subject: [PATCH 14/36] Tweaks for some pylint warnings (#858) Minor adjustments to both display in vscode and quiet some pylint warnings. Note: in the future I'd like to get some changes from #330 incorporated to allow more self-contained config examples, with relative paths support, added, which means changing a pile of APIs, in which case we can probably make all of these require named position values and remove those exceptions. --- .vscode/settings.json | 4 ++++ mlos_bench/mlos_bench/services/config_persistence.py | 9 ++++++--- .../mlos_bench/services/remote/azure/azure_auth.py | 2 +- .../mlos_bench/services/remote/ssh/ssh_fileshare.py | 2 +- .../mlos_bench/services/types/config_loader_type.py | 4 +++- mlos_bench/mlos_bench/tests/conftest.py | 2 +- .../services/remote/azure/azure_network_services_test.py | 2 +- .../services/remote/azure/azure_vm_services_test.py | 2 +- 8 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 6b9729290ff..982eb42c8dd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -125,6 +125,10 @@ ], "esbonio.sphinx.confDir": "${workspaceFolder}/doc/source", "esbonio.sphinx.buildDir": "${workspaceFolder}/doc/build/", + "pylint.severity": { + // display refactor warnings as information messages + "refactor": "Information" + }, "[python]": { "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true, diff --git a/mlos_bench/mlos_bench/services/config_persistence.py b/mlos_bench/mlos_bench/services/config_persistence.py index 78c0e1f3cec..72bfad007de 100644 --- a/mlos_bench/mlos_bench/services/config_persistence.py +++ b/mlos_bench/mlos_bench/services/config_persistence.py @@ -395,7 +395,7 @@ def build_scheduler( # pylint: disable=too-many-arguments _LOG.info("Created: Scheduler %s", inst) return inst - def build_environment( # pylint: disable=too-many-arguments + def build_environment( self, config: Dict[str, Any], tunables: TunableGroups, @@ -403,6 +403,7 @@ def build_environment( # pylint: disable=too-many-arguments parent_args: Optional[Dict[str, TunableValue]] = None, service: Optional[Service] = None, ) -> Environment: + # pylint: disable=too-many-arguments,too-many-positional-arguments """ Factory method for a new environment with a given config. @@ -566,7 +567,7 @@ def build_service( return self._build_composite_service(config_list, global_config, parent) - def load_environment( # pylint: disable=too-many-arguments + def load_environment( self, json_file_name: str, tunables: TunableGroups, @@ -574,6 +575,7 @@ def load_environment( # pylint: disable=too-many-arguments parent_args: Optional[Dict[str, TunableValue]] = None, service: Optional[Service] = None, ) -> Environment: + # pylint: disable=too-many-arguments,too-many-positional-arguments """ Load and build new environment from the config file. @@ -600,7 +602,7 @@ def load_environment( # pylint: disable=too-many-arguments assert isinstance(config, dict) return self.build_environment(config, tunables, global_config, parent_args, service) - def load_environment_list( # pylint: disable=too-many-arguments + def load_environment_list( self, json_file_name: str, tunables: TunableGroups, @@ -608,6 +610,7 @@ def load_environment_list( # pylint: disable=too-many-arguments parent_args: Optional[Dict[str, TunableValue]] = None, service: Optional[Service] = None, ) -> List[Environment]: + # pylint: disable=too-many-arguments,too-many-positional-arguments """ Load and build a list of environments from the config file. diff --git a/mlos_bench/mlos_bench/services/remote/azure/azure_auth.py b/mlos_bench/mlos_bench/services/remote/azure/azure_auth.py index dccb5740ce2..5aa46ab8ee7 100644 --- a/mlos_bench/mlos_bench/services/remote/azure/azure_auth.py +++ b/mlos_bench/mlos_bench/services/remote/azure/azure_auth.py @@ -111,7 +111,7 @@ def get_credential(self) -> TokenCredential: cert_bytes = b64decode(secret.value) # Reauthenticate as the service principal. - self._cred = CertificateCredential( + self._cred = CertificateCredential( # pylint: disable=redefined-variable-type tenant_id=tenant_id, client_id=sp_client_id, certificate_data=cert_bytes, diff --git a/mlos_bench/mlos_bench/services/remote/ssh/ssh_fileshare.py b/mlos_bench/mlos_bench/services/remote/ssh/ssh_fileshare.py index 383fcfbd20a..137ab024d1c 100644 --- a/mlos_bench/mlos_bench/services/remote/ssh/ssh_fileshare.py +++ b/mlos_bench/mlos_bench/services/remote/ssh/ssh_fileshare.py @@ -35,7 +35,7 @@ async def _start_file_copy( remote_path: str, recursive: bool = True, ) -> None: - # pylint: disable=too-many-arguments + # pylint: disable=too-many-arguments,too-many-positional-arguments """ Starts a file copy operation. diff --git a/mlos_bench/mlos_bench/services/types/config_loader_type.py b/mlos_bench/mlos_bench/services/types/config_loader_type.py index b2b6bdf0e5f..33adac67eb4 100644 --- a/mlos_bench/mlos_bench/services/types/config_loader_type.py +++ b/mlos_bench/mlos_bench/services/types/config_loader_type.py @@ -78,6 +78,7 @@ def build_environment( # pylint: disable=too-many-arguments parent_args: Optional[Dict[str, TunableValue]] = None, service: Optional["Service"] = None, ) -> "Environment": + # pylint: disable=too-many-arguments,too-many-positional-arguments """ Factory method for a new environment with a given config. @@ -106,7 +107,7 @@ def build_environment( # pylint: disable=too-many-arguments An instance of the `Environment` class initialized with `config`. """ - def load_environment_list( # pylint: disable=too-many-arguments + def load_environment_list( self, json_file_name: str, tunables: "TunableGroups", @@ -114,6 +115,7 @@ def load_environment_list( # pylint: disable=too-many-arguments parent_args: Optional[Dict[str, TunableValue]] = None, service: Optional["Service"] = None, ) -> List["Environment"]: + # pylint: disable=too-many-arguments,too-many-positional-arguments """ Load and build a list of environments from the config file. diff --git a/mlos_bench/mlos_bench/tests/conftest.py b/mlos_bench/mlos_bench/tests/conftest.py index a13c57a2cdb..bc0a8aa1897 100644 --- a/mlos_bench/mlos_bench/tests/conftest.py +++ b/mlos_bench/mlos_bench/tests/conftest.py @@ -143,7 +143,7 @@ def locked_docker_services( """A locked version of the docker_services fixture to implement xdist single instance locking. """ - # pylint: disable=too-many-arguments + # pylint: disable=too-many-arguments,too-many-positional-arguments # Mark the services as in use with the reader lock. docker_services_lock.acquire_read_lock() # Acquire the setup lock to prevent multiple setup operations at once. diff --git a/mlos_bench/mlos_bench/tests/services/remote/azure/azure_network_services_test.py b/mlos_bench/mlos_bench/tests/services/remote/azure/azure_network_services_test.py index 87dd78fd5a7..6dc1de468cd 100644 --- a/mlos_bench/mlos_bench/tests/services/remote/azure/azure_network_services_test.py +++ b/mlos_bench/mlos_bench/tests/services/remote/azure/azure_network_services_test.py @@ -75,7 +75,6 @@ def test_wait_network_deployment_retry( ], ) @patch("mlos_bench.services.remote.azure.azure_deployment_services.requests") -# pylint: disable=too-many-arguments def test_network_operation_status( mock_requests: MagicMock, azure_network_service: AzureNetworkService, @@ -85,6 +84,7 @@ def test_network_operation_status( operation_status: Status, ) -> None: """Test network operation status.""" + # pylint: disable=too-many-arguments,too-many-positional-arguments mock_response = MagicMock() mock_response.status_code = http_status_code mock_requests.post.return_value = mock_response diff --git a/mlos_bench/mlos_bench/tests/services/remote/azure/azure_vm_services_test.py b/mlos_bench/mlos_bench/tests/services/remote/azure/azure_vm_services_test.py index 6418da01a91..988177180e9 100644 --- a/mlos_bench/mlos_bench/tests/services/remote/azure/azure_vm_services_test.py +++ b/mlos_bench/mlos_bench/tests/services/remote/azure/azure_vm_services_test.py @@ -139,7 +139,6 @@ def test_azure_vm_service_custom_data(azure_auth_service: AzureAuthService) -> N ], ) @patch("mlos_bench.services.remote.azure.azure_deployment_services.requests") -# pylint: disable=too-many-arguments def test_vm_operation_status( mock_requests: MagicMock, azure_vm_service: AzureVMService, @@ -149,6 +148,7 @@ def test_vm_operation_status( operation_status: Status, ) -> None: """Test VM operation status.""" + # pylint: disable=too-many-arguments,too-many-positional-arguments mock_response = MagicMock() mock_response.status_code = http_status_code mock_requests.post.return_value = mock_response From 1f6a787c48452252094d41e02ee6d3ec73deb817 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Tue, 1 Oct 2024 16:44:13 -0500 Subject: [PATCH 15/36] Use scheduler in dummy runs (#861) Refactors tests in preparation for testing for #720. Adds `status()` output for MockEnv as well, though with a pending FIXME on improving the Status type in the status output that we'll address in a future PR. --- .../mlos_bench/environments/mock_env.py | 52 ++++-- mlos_bench/mlos_bench/storage/base_storage.py | 5 + .../mlos_bench/tests/storage/conftest.py | 5 - .../mlos_bench/tests/storage/sql/fixtures.py | 149 ++++++++---------- .../tests/storage/trial_data_test.py | 4 +- .../tests/storage/tunable_config_data_test.py | 9 +- mlos_viz/mlos_viz/tests/conftest.py | 1 - 7 files changed, 117 insertions(+), 108 deletions(-) diff --git a/mlos_bench/mlos_bench/environments/mock_env.py b/mlos_bench/mlos_bench/environments/mock_env.py index 765deb05b3a..6d3309f35b5 100644 --- a/mlos_bench/mlos_bench/environments/mock_env.py +++ b/mlos_bench/mlos_bench/environments/mock_env.py @@ -7,7 +7,7 @@ import logging import random from datetime import datetime -from typing import Dict, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import numpy @@ -61,11 +61,26 @@ def __init__( # pylint: disable=too-many-arguments service=service, ) seed = int(self.config.get("mock_env_seed", -1)) - self._random = random.Random(seed or None) if seed >= 0 else None + self._run_random = random.Random(seed or None) if seed >= 0 else None + self._status_random = random.Random(seed or None) if seed >= 0 else None self._range = self.config.get("mock_env_range") self._metrics = self.config.get("mock_env_metrics", ["score"]) self._is_ready = True + def _produce_metrics(self, rand: Optional[random.Random]) -> Dict[str, TunableValue]: + # Simple convex function of all tunable parameters. + score = numpy.mean( + numpy.square([self._normalized(tunable) for (tunable, _group) in self._tunable_params]) + ) + + # Add noise and shift the benchmark value from [0, 1] to a given range. + noise = rand.gauss(0, self._NOISE_VAR) if rand else 0 + score = numpy.clip(score + noise, 0, 1) + if self._range: + score = self._range[0] + score * (self._range[1] - self._range[0]) + + return {metric: score for metric in self._metrics} + def run(self) -> Tuple[Status, datetime, Optional[Dict[str, TunableValue]]]: """ Produce mock benchmark data for one experiment. @@ -82,19 +97,30 @@ def run(self) -> Tuple[Status, datetime, Optional[Dict[str, TunableValue]]]: (status, timestamp, _) = result = super().run() if not status.is_ready(): return result + metrics = self._produce_metrics(self._run_random) + return (Status.SUCCEEDED, timestamp, metrics) - # Simple convex function of all tunable parameters. - score = numpy.mean( - numpy.square([self._normalized(tunable) for (tunable, _group) in self._tunable_params]) - ) - - # Add noise and shift the benchmark value from [0, 1] to a given range. - noise = self._random.gauss(0, self._NOISE_VAR) if self._random else 0 - score = numpy.clip(score + noise, 0, 1) - if self._range: - score = self._range[0] + score * (self._range[1] - self._range[0]) + def status(self) -> Tuple[Status, datetime, List[Tuple[datetime, str, Any]]]: + """ + Produce mock benchmark status telemetry for one experiment. - return (Status.SUCCEEDED, timestamp, {metric: score for metric in self._metrics}) + Returns + ------- + (benchmark_status, timestamp, telemetry) : (Status, datetime, list) + 3-tuple of (benchmark status, timestamp, telemetry) values. + `timestamp` is UTC time stamp of the status; it's current time by default. + `telemetry` is a list (maybe empty) of (timestamp, metric, value) triplets. + """ + (status, timestamp, _) = result = super().status() + if not status.is_ready(): + return result + metrics = self._produce_metrics(self._status_random) + return ( + # FIXME: this causes issues if we report RUNNING instead of READY + Status.READY, + timestamp, + [(timestamp, metric, score) for (metric, score) in metrics.items()], + ) @staticmethod def _normalized(tunable: Tunable) -> float: diff --git a/mlos_bench/mlos_bench/storage/base_storage.py b/mlos_bench/mlos_bench/storage/base_storage.py index d3e9b6583d5..867c4e0bc02 100644 --- a/mlos_bench/mlos_bench/storage/base_storage.py +++ b/mlos_bench/mlos_bench/storage/base_storage.py @@ -215,6 +215,11 @@ def description(self) -> str: """Get the Experiment's description.""" return self._description + @property + def root_env_config(self) -> str: + """Get the Experiment's root Environment config file path.""" + return self._root_env_config + @property def tunables(self) -> TunableGroups: """Get the Experiment's tunables.""" diff --git a/mlos_bench/mlos_bench/tests/storage/conftest.py b/mlos_bench/mlos_bench/tests/storage/conftest.py index 52b0fdcd533..a1437052823 100644 --- a/mlos_bench/mlos_bench/tests/storage/conftest.py +++ b/mlos_bench/mlos_bench/tests/storage/conftest.py @@ -15,11 +15,6 @@ exp_storage = sql_storage_fixtures.exp_storage exp_no_tunables_storage = sql_storage_fixtures.exp_no_tunables_storage mixed_numerics_exp_storage = sql_storage_fixtures.mixed_numerics_exp_storage -exp_storage_with_trials = sql_storage_fixtures.exp_storage_with_trials -exp_no_tunables_storage_with_trials = sql_storage_fixtures.exp_no_tunables_storage_with_trials -mixed_numerics_exp_storage_with_trials = ( - sql_storage_fixtures.mixed_numerics_exp_storage_with_trials -) exp_data = sql_storage_fixtures.exp_data exp_no_tunables_data = sql_storage_fixtures.exp_no_tunables_data mixed_numerics_exp_data = sql_storage_fixtures.mixed_numerics_exp_data diff --git a/mlos_bench/mlos_bench/tests/storage/sql/fixtures.py b/mlos_bench/mlos_bench/tests/storage/sql/fixtures.py index 8a9065e4367..4e92d9ab9d1 100644 --- a/mlos_bench/mlos_bench/tests/storage/sql/fixtures.py +++ b/mlos_bench/mlos_bench/tests/storage/sql/fixtures.py @@ -4,16 +4,14 @@ # """Test fixtures for mlos_bench storage.""" -from datetime import datetime -from random import random from random import seed as rand_seed -from typing import Generator, Optional +from typing import Generator import pytest -from pytz import UTC -from mlos_bench.environments.status import Status +from mlos_bench.environments.mock_env import MockEnv from mlos_bench.optimizers.mock_optimizer import MockOptimizer +from mlos_bench.schedulers.sync_scheduler import SyncScheduler from mlos_bench.storage.base_experiment_data import ExperimentData from mlos_bench.storage.sql.storage import SqlStorage from mlos_bench.tests import SEED @@ -107,22 +105,45 @@ def mixed_numerics_exp_storage( def _dummy_run_exp( + storage: SqlStorage, exp: SqlStorage.Experiment, - tunable_name: Optional[str], -) -> SqlStorage.Experiment: - """Generates data by doing a simulated run of the given experiment.""" - # Add some trials to that experiment. - # Note: we're just fabricating some made up function for the ML libraries to try and learn. - base_score = 10.0 - if tunable_name: - tunable = exp.tunables.get_tunable(tunable_name)[0] - assert isinstance(tunable.default, int) - (tunable_min, tunable_max) = tunable.range - tunable_range = tunable_max - tunable_min +) -> ExperimentData: + """ + Generates data by doing a simulated run of the given experiment. + + Parameters + ---------- + storage : SqlStorage + The storage object to use. + exp : SqlStorage.Experiment + The experiment to "run". + Note: this particular object won't be updated, but a new one will be created + from its metadata. + + Returns + ------- + ExperimentData + The data generated by the simulated run. + """ + # pylint: disable=too-many-locals + rand_seed(SEED) + + env = MockEnv( + name="Test Env", + config={ + "tunable_params": list(exp.tunables.get_covariant_group_names()), + "mock_env_seed": SEED, + "mock_env_range": [60, 120], + "mock_env_metrics": ["score"], + }, + tunables=exp.tunables, + ) + opt = MockOptimizer( tunables=exp.tunables, config={ + "optimization_targets": exp.opt_targets, "seed": SEED, # This should be the default, so we leave it omitted for now to test the default. # But the test logic relies on this (e.g., trial 1 is config 1 is the @@ -130,97 +151,53 @@ def _dummy_run_exp( # "start_with_defaults": True, }, ) - assert opt.start_with_defaults - for config_i in range(CONFIG_COUNT): - tunables = opt.suggest() - for repeat_j in range(CONFIG_TRIAL_REPEAT_COUNT): - trial = exp.new_trial( - tunables=tunables.copy(), - config={ - "trial_number": config_i * CONFIG_TRIAL_REPEAT_COUNT + repeat_j + 1, - **{ - f"opt_{key}_{i}": val - for (i, opt_target) in enumerate(exp.opt_targets.items()) - for (key, val) in zip(["target", "direction"], opt_target) - }, - }, - ) - if exp.tunables: - assert trial.tunable_config_id == config_i + 1 - else: - assert trial.tunable_config_id == 1 - if tunable_name: - tunable_value = float(tunables.get_tunable(tunable_name)[0].numerical_value) - tunable_value_norm = base_score * (tunable_value - tunable_min) / tunable_range - else: - tunable_value_norm = 0 - timestamp = datetime.now(UTC) - trial.update_telemetry( - status=Status.RUNNING, - timestamp=timestamp, - metrics=[ - (timestamp, "some-metric", tunable_value_norm + random() / 100), - ], - ) - trial.update( - Status.SUCCEEDED, - timestamp, - metrics={ - # Give some variance on the score. - # And some influence from the tunable value. - "score": tunable_value_norm - + random() / 100 - }, - ) - return exp - -@pytest.fixture -def exp_storage_with_trials(exp_storage: SqlStorage.Experiment) -> SqlStorage.Experiment: - """Test fixture for Experiment using in-memory SQLite3 storage.""" - return _dummy_run_exp(exp_storage, tunable_name="kernel_sched_latency_ns") - - -@pytest.fixture -def exp_no_tunables_storage_with_trials( - exp_no_tunables_storage: SqlStorage.Experiment, -) -> SqlStorage.Experiment: - """Test fixture for Experiment using in-memory SQLite3 storage.""" - assert not exp_no_tunables_storage.tunables - return _dummy_run_exp(exp_no_tunables_storage, tunable_name=None) + scheduler = SyncScheduler( + # All config values can be overridden from global config + config={ + "experiment_id": exp.experiment_id, + "trial_id": exp.trial_id, + "config_id": -1, + "trial_config_repeat_count": CONFIG_TRIAL_REPEAT_COUNT, + "max_trials": CONFIG_COUNT * CONFIG_TRIAL_REPEAT_COUNT, + }, + global_config={}, + environment=env, + optimizer=opt, + storage=storage, + root_env_config=exp.root_env_config, + ) + # Add some trial data to that experiment by "running" it. + with scheduler: + scheduler.start() + scheduler.teardown() -@pytest.fixture -def mixed_numerics_exp_storage_with_trials( - mixed_numerics_exp_storage: SqlStorage.Experiment, -) -> SqlStorage.Experiment: - """Test fixture for Experiment using in-memory SQLite3 storage.""" - tunable = next(iter(mixed_numerics_exp_storage.tunables))[0] - return _dummy_run_exp(mixed_numerics_exp_storage, tunable_name=tunable.name) + return storage.experiments[exp.experiment_id] @pytest.fixture def exp_data( storage: SqlStorage, - exp_storage_with_trials: SqlStorage.Experiment, + exp_storage: SqlStorage.Experiment, ) -> ExperimentData: """Test fixture for ExperimentData.""" - return storage.experiments[exp_storage_with_trials.experiment_id] + return _dummy_run_exp(storage, exp_storage) @pytest.fixture def exp_no_tunables_data( storage: SqlStorage, - exp_no_tunables_storage_with_trials: SqlStorage.Experiment, + exp_no_tunables_storage: SqlStorage.Experiment, ) -> ExperimentData: """Test fixture for ExperimentData with no tunable configs.""" - return storage.experiments[exp_no_tunables_storage_with_trials.experiment_id] + return _dummy_run_exp(storage, exp_no_tunables_storage) @pytest.fixture def mixed_numerics_exp_data( storage: SqlStorage, - mixed_numerics_exp_storage_with_trials: SqlStorage.Experiment, + mixed_numerics_exp_storage: SqlStorage.Experiment, ) -> ExperimentData: """Test fixture for ExperimentData with mixed numerical tunable types.""" - return storage.experiments[mixed_numerics_exp_storage_with_trials.experiment_id] + return _dummy_run_exp(storage, mixed_numerics_exp_storage) diff --git a/mlos_bench/mlos_bench/tests/storage/trial_data_test.py b/mlos_bench/mlos_bench/tests/storage/trial_data_test.py index 9fe59b426bf..ea513eace2e 100644 --- a/mlos_bench/mlos_bench/tests/storage/trial_data_test.py +++ b/mlos_bench/mlos_bench/tests/storage/trial_data_test.py @@ -20,9 +20,9 @@ def test_exp_trial_data(exp_data: ExperimentData) -> None: assert trial.trial_id == trial_id assert trial.tunable_config_id == expected_config_id assert trial.status == Status.SUCCEEDED - assert trial.metadata_dict["trial_number"] == trial_id + assert trial.metadata_dict["repeat_i"] == 1 assert list(trial.results_dict.keys()) == ["score"] - assert trial.results_dict["score"] == pytest.approx(0.0, abs=0.1) + assert trial.results_dict["score"] == pytest.approx(73.27, 0.01) assert isinstance(trial.ts_start, datetime) assert isinstance(trial.ts_end, datetime) # Note: tests for telemetry are in test_update_telemetry() diff --git a/mlos_bench/mlos_bench/tests/storage/tunable_config_data_test.py b/mlos_bench/mlos_bench/tests/storage/tunable_config_data_test.py index 755fc0205a2..8721bbe4512 100644 --- a/mlos_bench/mlos_bench/tests/storage/tunable_config_data_test.py +++ b/mlos_bench/mlos_bench/tests/storage/tunable_config_data_test.py @@ -4,7 +4,10 @@ # """Unit tests for loading the TunableConfigData.""" +from math import ceil + from mlos_bench.storage.base_experiment_data import ExperimentData +from mlos_bench.tests.storage import CONFIG_TRIAL_REPEAT_COUNT from mlos_bench.tunables.tunable_groups import TunableGroups @@ -27,10 +30,14 @@ def test_trial_metadata(exp_data: ExperimentData) -> None: """Check expected return values for TunableConfigData metadata.""" assert exp_data.objectives == {"score": "min"} for trial_id, trial in exp_data.trials.items(): + assert trial.tunable_config_id == ceil(trial_id / CONFIG_TRIAL_REPEAT_COUNT) assert trial.metadata_dict == { + # Only the first CONFIG_TRIAL_REPEAT_COUNT set should be the defaults. + "is_defaults": str(trial_id <= CONFIG_TRIAL_REPEAT_COUNT), "opt_target_0": "score", "opt_direction_0": "min", - "trial_number": trial_id, + "optimizer": "MockOptimizer", + "repeat_i": ((trial_id - 1) % CONFIG_TRIAL_REPEAT_COUNT) + 1, } diff --git a/mlos_viz/mlos_viz/tests/conftest.py b/mlos_viz/mlos_viz/tests/conftest.py index 228609ba09f..9299ebb377f 100644 --- a/mlos_viz/mlos_viz/tests/conftest.py +++ b/mlos_viz/mlos_viz/tests/conftest.py @@ -11,7 +11,6 @@ storage = sql_storage_fixtures.storage exp_storage = sql_storage_fixtures.exp_storage -exp_storage_with_trials = sql_storage_fixtures.exp_storage_with_trials exp_data = sql_storage_fixtures.exp_data tunable_groups_config = tunable_groups_fixtures.tunable_groups_config From 87381e328caaaea13c77fa3236be18f06900089e Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Tue, 1 Oct 2024 17:30:54 -0500 Subject: [PATCH 16/36] Temporarily avoid broken libpq conda package (#863) Avoids the following error that results when the [latest libpq](https://anaconda.org/conda-forge/libpq) (17.0) is installed from conda-forge: ``` Error: pg_config --includedir is empty ``` --- conda-envs/mlos-3.10.yml | 3 ++- conda-envs/mlos-3.11.yml | 3 ++- conda-envs/mlos-3.12.yml | 3 ++- conda-envs/mlos-3.8.yml | 3 ++- conda-envs/mlos-3.9.yml | 3 ++- conda-envs/mlos-windows.yml | 3 ++- conda-envs/mlos.yml | 3 ++- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/conda-envs/mlos-3.10.yml b/conda-envs/mlos-3.10.yml index f4c63f118a6..9be0bc599d7 100644 --- a/conda-envs/mlos-3.10.yml +++ b/conda-envs/mlos-3.10.yml @@ -20,7 +20,8 @@ dependencies: - pandas - pyarrow - swig - - libpq + # FIXME: Temporarily avoid broken libpq that's missing client headers. + - libpq<17.0 - python=3.10 # See comments in mlos.yml. #- gcc_linux-64 diff --git a/conda-envs/mlos-3.11.yml b/conda-envs/mlos-3.11.yml index 8114fdca644..900168cb314 100644 --- a/conda-envs/mlos-3.11.yml +++ b/conda-envs/mlos-3.11.yml @@ -20,7 +20,8 @@ dependencies: - pandas - pyarrow - swig - - libpq + # FIXME: Temporarily avoid broken libpq that's missing client headers. + - libpq<17.0 - python=3.11 # See comments in mlos.yml. #- gcc_linux-64 diff --git a/conda-envs/mlos-3.12.yml b/conda-envs/mlos-3.12.yml index 7255766b1b4..db5147e9c1a 100644 --- a/conda-envs/mlos-3.12.yml +++ b/conda-envs/mlos-3.12.yml @@ -22,7 +22,8 @@ dependencies: - pandas - pyarrow - swig - - libpq + # FIXME: Temporarily avoid broken libpq that's missing client headers. + - libpq<17.0 - python=3.12 # See comments in mlos.yml. #- gcc_linux-64 diff --git a/conda-envs/mlos-3.8.yml b/conda-envs/mlos-3.8.yml index 756f7908582..57e8ab5d59d 100644 --- a/conda-envs/mlos-3.8.yml +++ b/conda-envs/mlos-3.8.yml @@ -20,7 +20,8 @@ dependencies: - pandas - pyarrow - swig - - libpq + # FIXME: Temporarily avoid broken libpq that's missing client headers. + - libpq<17.0 - python=3.8 # See comments in mlos.yml. #- gcc_linux-64 diff --git a/conda-envs/mlos-3.9.yml b/conda-envs/mlos-3.9.yml index 3aab626ce10..6385482bbe7 100644 --- a/conda-envs/mlos-3.9.yml +++ b/conda-envs/mlos-3.9.yml @@ -20,7 +20,8 @@ dependencies: - pandas - pyarrow - swig - - libpq + # FIXME: Temporarily avoid broken libpq that's missing client headers. + - libpq<17.0 - python=3.9 # See comments in mlos.yml. #- gcc_linux-64 diff --git a/conda-envs/mlos-windows.yml b/conda-envs/mlos-windows.yml index e27e5b42e09..c104376183c 100644 --- a/conda-envs/mlos-windows.yml +++ b/conda-envs/mlos-windows.yml @@ -21,7 +21,8 @@ dependencies: - pandas - pyarrow - swig - - libpq + # FIXME: Temporarily avoid broken libpq that's missing client headers. + - libpq<17.0 - python # Install an SMAC requirement pre-compiled from conda-forge. # This also requires a more recent vs2015_runtime from conda-forge. diff --git a/conda-envs/mlos.yml b/conda-envs/mlos.yml index 34d1db429df..66b8b46efb1 100644 --- a/conda-envs/mlos.yml +++ b/conda-envs/mlos.yml @@ -20,7 +20,8 @@ dependencies: - pandas - pyarrow - swig - - libpq + # FIXME: Temporarily avoid broken libpq that's missing client headers. + - libpq<17.0 - python - pip: - bump2version From c69b7da1b53efe28278e5c84c5f4cc4b441b3040 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Tue, 1 Oct 2024 17:47:16 -0500 Subject: [PATCH 17/36] Add a Github PR template (#862) Adds a Github PR template to try and make it easier for contributors to understand what information is expected in a PR. --- .github/pull_request_template.md | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000000..c21c2a55ef7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,40 @@ +# Pull Request + +## Title + +_A brief, descriptive title of your changes (e.g., `Fix bug in data parser for edge cases`)_ + +--- + +## Description + +_Provide a concise summary of what this PR does. Explain why this change is necessary and what problems it addresses._ + +- **Issue link**: (optional) _Link the relevant issue number or GitHub Issue link if applicable (e.g., Closes #123)._ + +--- + +## Type of Change + +_Indicate the type of change by choosing one (or more) of the following:_ + +- 🛠️ Bug fix +- ✨ New feature +- ⚠️ Breaking change +- 🔄 Refactor +- 📝 Documentation update +- 🧪 Tests + +--- + +## Testing + +_Describe briefly how you tested your changes._ + +--- + +## Additional Notes (optional) + +_Add any additional context or information for reviewers._ + +--- From a85df21e1b767c2c44c0241d8afcc8b971224333 Mon Sep 17 00:00:00 2001 From: Eu Jing Chua Date: Thu, 3 Oct 2024 15:05:05 -0700 Subject: [PATCH 18/36] Move to azure VM managed run commands (#853) A comparison of the existing action run command API vs new managed version: https://learn.microsoft.com/en-us/azure/virtual-machines/run-command-overview The main benefits of moving to the new managed version are: - Much longer remote execution timeouts can be set beyond the current 90 mins. (Supposedly supports up to days) - Returns the exit code of script ran. - Can run multiple managed run commands in parallel. - Supports storing large script output into blob storage (not used in this PR) The first point is really needed for steps like the benchbase loading phase, where loading TPCC scale factor 200+ is a struggle due to hitting the previous 90 min timeout limit. This should be possible by setting `"pollTimeout": 5400` to much higher values as needed in the relevant VM service configs. --------- Co-authored-by: Eu Jing Chua Co-authored-by: Brian Kroth Co-authored-by: Sergiy Matusevych --- .../environments/remote/remote_env.py | 26 ++- .../remote/azure/azure_vm_services.py | 153 +++++++++++++++--- .../remote/azure/azure_vm_services_test.py | 86 ++++++---- 3 files changed, 212 insertions(+), 53 deletions(-) diff --git a/mlos_bench/mlos_bench/environments/remote/remote_env.py b/mlos_bench/mlos_bench/environments/remote/remote_env.py index 1c025a335d1..c3535d1a6a0 100644 --- a/mlos_bench/mlos_bench/environments/remote/remote_env.py +++ b/mlos_bench/mlos_bench/environments/remote/remote_env.py @@ -9,6 +9,7 @@ """ import logging +import re from datetime import datetime from typing import Dict, Iterable, Optional, Tuple @@ -32,6 +33,8 @@ class RemoteEnv(ScriptEnv): e.g. Application Environment """ + _RE_SPECIAL = re.compile(r"\W+") + def __init__( # pylint: disable=too-many-arguments self, *, @@ -72,6 +75,7 @@ def __init__( # pylint: disable=too-many-arguments ) self._wait_boot = self.config.get("wait_boot", False) + self._command_prefix = "mlos-" + self._RE_SPECIAL.sub("-", self.name).lower() + "-" assert self._service is not None and isinstance( self._service, SupportsRemoteExec @@ -116,7 +120,7 @@ def setup(self, tunables: TunableGroups, global_config: Optional[dict] = None) - if self._script_setup: _LOG.info("Set up the remote environment: %s", self) - (status, _timestamp, _output) = self._remote_exec(self._script_setup) + (status, _timestamp, _output) = self._remote_exec("setup", self._script_setup) _LOG.info("Remote set up complete: %s :: %s", self, status) self._is_ready = status.is_succeeded() else: @@ -145,7 +149,7 @@ def run(self) -> Tuple[Status, datetime, Optional[Dict[str, TunableValue]]]: if not (status.is_ready() and self._script_run): return result - (status, timestamp, output) = self._remote_exec(self._script_run) + (status, timestamp, output) = self._remote_exec("run", self._script_run) if status.is_succeeded() and output is not None: output = self._extract_stdout_results(output.get("stdout", "")) _LOG.info("Remote run complete: %s :: %s = %s", self, status, output) @@ -155,16 +159,22 @@ def teardown(self) -> None: """Clean up and shut down the remote environment.""" if self._script_teardown: _LOG.info("Remote teardown: %s", self) - (status, _timestamp, _output) = self._remote_exec(self._script_teardown) + (status, _timestamp, _output) = self._remote_exec("teardown", self._script_teardown) _LOG.info("Remote teardown complete: %s :: %s", self, status) super().teardown() - def _remote_exec(self, script: Iterable[str]) -> Tuple[Status, datetime, Optional[dict]]: + def _remote_exec( + self, + command_name: str, + script: Iterable[str], + ) -> Tuple[Status, datetime, Optional[dict]]: """ Run a script on the remote host. Parameters ---------- + command_name : str + Name of the command to be executed on the remote host. script : [str] List of commands to be executed on the remote host. @@ -175,10 +185,14 @@ def _remote_exec(self, script: Iterable[str]) -> Tuple[Status, datetime, Optiona Status is one of {PENDING, SUCCEEDED, FAILED, TIMED_OUT} """ env_params = self._get_env_params() - _LOG.debug("Submit script: %s with %s", self, env_params) + command_name = self._command_prefix + command_name + _LOG.debug("Submit command: %s with %s", command_name, env_params) (status, output) = self._remote_exec_service.remote_exec( script, - config=self._params, + config={ + **self._params, + "commandName": command_name, + }, env_params=env_params, ) _LOG.debug("Script submitted: %s %s :: %s", self, status, output) diff --git a/mlos_bench/mlos_bench/services/remote/azure/azure_vm_services.py b/mlos_bench/mlos_bench/services/remote/azure/azure_vm_services.py index 3d390645f5e..b62ede5fab5 100644 --- a/mlos_bench/mlos_bench/services/remote/azure/azure_vm_services.py +++ b/mlos_bench/mlos_bench/services/remote/azure/azure_vm_services.py @@ -6,6 +6,7 @@ import json import logging +from datetime import datetime from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union import requests @@ -99,15 +100,25 @@ class AzureVMService( "?api-version=2022-03-01" ) - # From: https://docs.microsoft.com/en-us/rest/api/compute/virtual-machines/run-command + # From: + # https://learn.microsoft.com/en-us/rest/api/compute/virtual-machine-run-commands/create-or-update _URL_REXEC_RUN = ( "https://management.azure.com" "/subscriptions/{subscription}" "/resourceGroups/{resource_group}" "/providers/Microsoft.Compute" "/virtualMachines/{vm_name}" - "/runCommand" - "?api-version=2022-03-01" + "/runcommands/{command_name}" + "?api-version=2024-07-01" + ) + _URL_REXEC_RESULT = ( + "https://management.azure.com" + "/subscriptions/{subscription}" + "/resourceGroups/{resource_group}" + "/providers/Microsoft.Compute" + "/virtualMachines/{vm_name}" + "/runcommands/{command_name}" + "?$expand=instanceView&api-version=2024-07-01" ) def __init__( @@ -231,6 +242,28 @@ def wait_host_operation(self, params: dict) -> Tuple[Status, dict]: params.setdefault(f"{params['vmName']}-deployment") return self._wait_while(self._check_operation_status, Status.RUNNING, params) + def wait_remote_exec_operation(self, params: dict) -> Tuple["Status", dict]: + """ + Waits for a pending remote execution on an Azure VM to resolve to SUCCEEDED or + FAILED. Return TIMED_OUT when timing out. + + Parameters + ---------- + params: dict + Flat dictionary of (key, value) pairs of tunable parameters. + Must have the "asyncResultsUrl" key to get the results. + If the key is not present, return Status.PENDING. + + Returns + ------- + result : (Status, dict) + A pair of Status and result. + Status is one of {PENDING, SUCCEEDED, FAILED, TIMED_OUT} + Result is info on the operation runtime if SUCCEEDED, otherwise {}. + """ + _LOG.info("Wait for run command %s on VM %s", params["commandName"], params["vmName"]) + return self._wait_while(self._check_remote_exec_status, Status.RUNNING, params) + def wait_os_operation(self, params: dict) -> Tuple["Status", dict]: return self.wait_host_operation(params) @@ -481,6 +514,8 @@ def remote_exec( "subscription", "resourceGroup", "vmName", + "commandName", + "location", ], ) @@ -488,21 +523,28 @@ def remote_exec( _LOG.info("Run a script on VM: %s\n %s", config["vmName"], "\n ".join(script)) json_req = { - "commandId": "RunShellScript", - "script": list(script), - "parameters": [{"name": key, "value": val} for (key, val) in env_params.items()], + "location": config["location"], + "properties": { + "source": {"script": "; ".join(script)}, + "protectedParameters": [ + {"name": key, "value": val} for (key, val) in env_params.items() + ], + "timeoutInSeconds": int(self._poll_timeout), + "asyncExecution": True, + }, } url = self._URL_REXEC_RUN.format( subscription=config["subscription"], resource_group=config["resourceGroup"], vm_name=config["vmName"], + command_name=config["commandName"], ) if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug("Request: POST %s\n%s", url, json.dumps(json_req, indent=2)) + _LOG.debug("Request: PUT %s\n%s", url, json.dumps(json_req, indent=2)) - response = requests.post( + response = requests.put( url, json=json_req, headers=self._get_headers(), @@ -518,19 +560,73 @@ def remote_exec( else: _LOG.info("Response: %s", response) - if response.status_code == 200: - # TODO: extract the results from JSON response - return (Status.SUCCEEDED, config) - elif response.status_code == 202: + if response.status_code in {200, 201}: + results_url = self._URL_REXEC_RESULT.format( + subscription=config["subscription"], + resource_group=config["resourceGroup"], + vm_name=config["vmName"], + command_name=config["commandName"], + ) return ( Status.PENDING, - {**config, "asyncResultsUrl": response.headers.get("Azure-AsyncOperation")}, + {**config, "asyncResultsUrl": results_url}, ) else: _LOG.error("Response: %s :: %s", response, response.text) - # _LOG.error("Bad Request:\n%s", response.request.body) return (Status.FAILED, {}) + def _check_remote_exec_status(self, params: dict) -> Tuple[Status, dict]: + """ + Checks the status of a pending remote execution on an Azure VM. + + Parameters + ---------- + params: dict + Flat dictionary of (key, value) pairs of tunable parameters. + Must have the "asyncResultsUrl" key to get the results. + If the key is not present, return Status.PENDING. + + Returns + ------- + result : (Status, dict) + A pair of Status and result. + Status is one of {PENDING, RUNNING, SUCCEEDED, FAILED} + Result is info on the operation runtime if SUCCEEDED, otherwise {}. + """ + url = params.get("asyncResultsUrl") + if url is None: + return Status.PENDING, {} + + session = self._get_session(params) + try: + response = session.get(url, timeout=self._request_timeout) + except requests.exceptions.ReadTimeout: + _LOG.warning("Request timed out after %.2f s: %s", self._request_timeout, url) + return Status.RUNNING, {} + except requests.exceptions.RequestException as ex: + _LOG.exception("Error in request checking operation status", exc_info=ex) + return (Status.FAILED, {}) + + if _LOG.isEnabledFor(logging.DEBUG): + _LOG.debug( + "Response: %s\n%s", + response, + json.dumps(response.json(), indent=2) if response.content else "", + ) + + if response.status_code == 200: + output = response.json() + execution_state = ( + output.get("properties", {}).get("instanceView", {}).get("executionState") + ) + if execution_state in {"Running", "Pending"}: + return Status.RUNNING, {} + elif execution_state == "Succeeded": + return Status.SUCCEEDED, output + + _LOG.error("Response: %s :: %s", response, response.text) + return Status.FAILED, {} + def get_remote_exec_results(self, config: dict) -> Tuple[Status, dict]: """ Get the results of the asynchronously running command. @@ -547,13 +643,34 @@ def get_remote_exec_results(self, config: dict) -> Tuple[Status, dict]: result : (Status, dict) A pair of Status and result. Status is one of {PENDING, SUCCEEDED, FAILED, TIMED_OUT} - A dict can have an "stdout" key with the remote output. + A dict can have an "stdout" key with the remote output + and an "stderr" key for errors / warnings. """ _LOG.info("Check the results on VM: %s", config.get("vmName")) - (status, result) = self.wait_host_operation(config) + (status, result) = self.wait_remote_exec_operation(config) _LOG.debug("Result: %s :: %s", status, result) if not status.is_succeeded(): # TODO: Extract the telemetry and status from stdout, if available return (status, result) - val = result.get("properties", {}).get("output", {}).get("value", []) - return (status, {"stdout": val[0].get("message", "")} if val else {}) + + output = result.get("properties", {}).get("instanceView", {}) + exit_code = output.get("exitCode") + execution_state = output.get("executionState") + outputs = output.get("output", "").strip().split("\n") + errors = output.get("error", "").strip().split("\n") + + if execution_state == "Succeeded" and exit_code == 0: + status = Status.SUCCEEDED + else: + status = Status.FAILED + + return ( + status, + { + "stdout": outputs, + "stderr": errors, + "exitCode": exit_code, + "startTimestamp": datetime.fromisoformat(output["startTime"]), + "endTimestamp": datetime.fromisoformat(output["endTime"]), + }, + ) diff --git a/mlos_bench/mlos_bench/tests/services/remote/azure/azure_vm_services_test.py b/mlos_bench/mlos_bench/tests/services/remote/azure/azure_vm_services_test.py index 988177180e9..240a32e4c56 100644 --- a/mlos_bench/mlos_bench/tests/services/remote/azure/azure_vm_services_test.py +++ b/mlos_bench/mlos_bench/tests/services/remote/azure/azure_vm_services_test.py @@ -5,6 +5,7 @@ """Tests for mlos_bench.services.remote.azure.azure_vm_services.""" from copy import deepcopy +from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest @@ -273,8 +274,8 @@ def test_wait_vm_operation_retry( @pytest.mark.parametrize( ("http_status_code", "operation_status"), [ - (200, Status.SUCCEEDED), - (202, Status.PENDING), + (200, Status.PENDING), + (201, Status.PENDING), (401, Status.FAILED), (404, Status.FAILED), ], @@ -291,16 +292,18 @@ def test_remote_exec_status( mock_response = MagicMock() mock_response.status_code = http_status_code - mock_response.json = MagicMock( - return_value={ - "fake response": "body as json to dict", - } - ) - mock_requests.post.return_value = mock_response + mock_response.json.return_value = { + "fake response": "body as json to dict", + } + mock_requests.put.return_value = mock_response status, _ = azure_vm_service_remote_exec_only.remote_exec( script, - config={"vmName": "test-vm"}, + config={ + "vmName": "test-vm", + "commandName": "TEST_COMMAND", + "location": "TEST_LOCATION", + }, env_params={}, ) @@ -308,7 +311,7 @@ def test_remote_exec_status( @patch("mlos_bench.services.remote.azure.azure_vm_services.requests") -def test_remote_exec_headers_output( +def test_remote_exec_output( mock_requests: MagicMock, azure_vm_service_remote_exec_only: AzureVMService, ) -> None: @@ -318,18 +321,22 @@ def test_remote_exec_headers_output( script = ["command_1", "command_2"] mock_response = MagicMock() - mock_response.status_code = 202 + mock_response.status_code = 201 mock_response.headers = {"Azure-AsyncOperation": async_url_value} mock_response.json = MagicMock( return_value={ "fake response": "body as json to dict", } ) - mock_requests.post.return_value = mock_response + mock_requests.put.return_value = mock_response _, cmd_output = azure_vm_service_remote_exec_only.remote_exec( script, - config={"vmName": "test-vm"}, + config={ + "vmName": "test-vm", + "commandName": "TEST_COMMAND", + "location": "TEST_LOCATION", + }, env_params={ "param_1": 123, "param_2": "abc", @@ -337,12 +344,18 @@ def test_remote_exec_headers_output( ) assert async_url_key in cmd_output - assert cmd_output[async_url_key] == async_url_value - assert mock_requests.post.call_args[1]["json"] == { - "commandId": "RunShellScript", - "script": script, - "parameters": [{"name": "param_1", "value": 123}, {"name": "param_2", "value": "abc"}], + assert mock_requests.put.call_args[1]["json"] == { + "location": "TEST_LOCATION", + "properties": { + "source": {"script": "; ".join(script)}, + "protectedParameters": [ + {"name": "param_1", "value": 123}, + {"name": "param_2", "value": "abc"}, + ], + "timeoutInSeconds": 2, + "asyncExecution": True, + }, } @@ -353,14 +366,23 @@ def test_remote_exec_headers_output( Status.SUCCEEDED, { "properties": { - "output": { - "value": [ - {"message": "DUMMY_STDOUT_STDERR"}, - ] + "instanceView": { + "output": "DUMMY_STDOUT\n", + "error": "DUMMY_STDERR\n", + "executionState": "Succeeded", + "exitCode": 0, + "startTime": "2024-01-01T00:00:00+00:00", + "endTime": "2024-01-01T00:01:00+00:00", } } }, - {"stdout": "DUMMY_STDOUT_STDERR"}, + { + "stdout": ["DUMMY_STDOUT"], + "stderr": ["DUMMY_STDERR"], + "exitCode": 0, + "startTimestamp": datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "endTimestamp": datetime(2024, 1, 1, 0, 1, 0, tzinfo=timezone.utc), + }, ), (Status.PENDING, {}, {}), (Status.FAILED, {}, {}), @@ -373,15 +395,21 @@ def test_get_remote_exec_results( results_output: dict, ) -> None: """Test getting the results of the remote execution on Azure.""" - params = {"asyncResultsUrl": "DUMMY_ASYNC_URL"} + params = { + "asyncResultsUrl": "DUMMY_ASYNC_URL", + } - mock_wait_host_operation = MagicMock() - mock_wait_host_operation.return_value = (operation_status, wait_output) - # azure_vm_service.wait_host_operation = mock_wait_host_operation - setattr(azure_vm_service_remote_exec_only, "wait_host_operation", mock_wait_host_operation) + mock_wait_remote_exec_operation = MagicMock() + mock_wait_remote_exec_operation.return_value = (operation_status, wait_output) + # azure_vm_service.wait_remote_exec_operation = mock_wait_remote_exec_operation + setattr( + azure_vm_service_remote_exec_only, + "wait_remote_exec_operation", + mock_wait_remote_exec_operation, + ) status, cmd_output = azure_vm_service_remote_exec_only.get_remote_exec_results(params) assert status == operation_status - assert mock_wait_host_operation.call_args[0][0] == params + assert mock_wait_remote_exec_operation.call_args[0][0] == params assert cmd_output == results_output From df8eac00d76c7661b30b885068b3651548f534d1 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Thu, 3 Oct 2024 17:17:14 -0500 Subject: [PATCH 19/36] Include strtobool from older version of python (#866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title Include `strtobool` from a now deprecated version of python. --- ## Description - Closes #865 --- ## Type of Change - 🛠️ Bug fix --- --------- Co-authored-by: Sergiy Matusevych --- .../mlos_bench/optimizers/base_optimizer.py | 2 +- .../mlos_bench/storage/base_experiment_data.py | 2 +- mlos_bench/mlos_bench/util.py | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/mlos_bench/mlos_bench/optimizers/base_optimizer.py b/mlos_bench/mlos_bench/optimizers/base_optimizer.py index dad2ba507fd..d9a854b476e 100644 --- a/mlos_bench/mlos_bench/optimizers/base_optimizer.py +++ b/mlos_bench/mlos_bench/optimizers/base_optimizer.py @@ -8,7 +8,6 @@ import logging from abc import ABCMeta, abstractmethod -from distutils.util import strtobool # pylint: disable=deprecated-module from types import TracebackType from typing import Dict, Optional, Sequence, Tuple, Type, Union @@ -21,6 +20,7 @@ from mlos_bench.services.base_service import Service from mlos_bench.tunables.tunable import TunableValue from mlos_bench.tunables.tunable_groups import TunableGroups +from mlos_bench.util import strtobool _LOG = logging.getLogger(__name__) diff --git a/mlos_bench/mlos_bench/storage/base_experiment_data.py b/mlos_bench/mlos_bench/storage/base_experiment_data.py index a867cd83dad..97f946ef92b 100644 --- a/mlos_bench/mlos_bench/storage/base_experiment_data.py +++ b/mlos_bench/mlos_bench/storage/base_experiment_data.py @@ -5,12 +5,12 @@ """Base interface for accessing the stored benchmark experiment data.""" from abc import ABCMeta, abstractmethod -from distutils.util import strtobool # pylint: disable=deprecated-module from typing import TYPE_CHECKING, Dict, Literal, Optional, Tuple import pandas from mlos_bench.storage.base_tunable_config_data import TunableConfigData +from mlos_bench.util import strtobool if TYPE_CHECKING: from mlos_bench.storage.base_trial_data import TrialData diff --git a/mlos_bench/mlos_bench/util.py b/mlos_bench/mlos_bench/util.py index 64d36009661..945359bcddd 100644 --- a/mlos_bench/mlos_bench/util.py +++ b/mlos_bench/mlos_bench/util.py @@ -44,6 +44,24 @@ BaseTypes = Union["Environment", "Optimizer", "Scheduler", "Service", "Storage"] +# Adjusted from https://github.com/python/cpython/blob/v3.11.10/Lib/distutils/util.py#L308 +# See Also: https://github.com/microsoft/MLOS/issues/865 +def strtobool(val: str) -> bool: + """ + Convert a string representation of truth to true (1) or false (0). + + True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values are 'n', 'no', + 'f', 'false', 'off', and '0'. Raises ValueError if 'val' is anything else. + """ + val = val.lower() + if val in {"y", "yes", "t", "true", "on", "1"}: + return True + elif val in {"n", "no", "f", "false", "off", "0"}: + return False + else: + raise ValueError(f"Invalid Boolean value: '{val}'") + + def preprocess_dynamic_configs(*, dest: dict, source: Optional[dict] = None) -> dict: """ Replaces all $name values in the destination config with the corresponding value From dbaccea50329e66d9d2e5a58d2263a5cac9bc9c4 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Fri, 4 Oct 2024 16:47:06 -0500 Subject: [PATCH 20/36] Allow empty tunable values to represent the defaults (#868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title Allows an empty dictionary (object) to represent the default tunable values for tunable params. --- ## Description This should make it easier to maintain configs that loop over a chosen set of tunable values, where some of the tunable values are the default values without needing to copy/paste those values from the tunable params definitions. - See Also: [discussion in #855](https://github.com/microsoft/MLOS/pull/855#discussion_r1786896189) --- ## Type of Change - ✨ New feature --- ## Testing Extends existing testing infrastructure to check that this works. --- --- .../tunables/tunable-values-schema.json | 1 - .../bare-tunable-values-with-schema.jsonc | 2 ++ .../empty-tunable-values-with-schema.jsonc | 4 +++ .../empty-tunable-values-without-schema.jsonc | 3 +++ .../tests/tunables/tunables_assign_test.py | 26 +++++++++++++++++++ .../mlos_bench/tunables/tunable_groups.py | 12 +++++++++ 6 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 mlos_bench/mlos_bench/tests/config/schemas/tunable-values/test-cases/good/full/empty-tunable-values-with-schema.jsonc create mode 100644 mlos_bench/mlos_bench/tests/config/schemas/tunable-values/test-cases/good/partial/empty-tunable-values-without-schema.jsonc diff --git a/mlos_bench/mlos_bench/config/schemas/tunables/tunable-values-schema.json b/mlos_bench/mlos_bench/config/schemas/tunables/tunable-values-schema.json index 186007a4499..e14130dbca5 100644 --- a/mlos_bench/mlos_bench/config/schemas/tunables/tunable-values-schema.json +++ b/mlos_bench/mlos_bench/config/schemas/tunables/tunable-values-schema.json @@ -12,7 +12,6 @@ "type": ["string", "number", "boolean"] } }, - "minProperties": 1, "not": { "required": ["tunable_values"] } diff --git a/mlos_bench/mlos_bench/tests/config/schemas/tunable-values/test-cases/good/full/bare-tunable-values-with-schema.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/tunable-values/test-cases/good/full/bare-tunable-values-with-schema.jsonc index a91c629b61c..2e5379d0df7 100644 --- a/mlos_bench/mlos_bench/tests/config/schemas/tunable-values/test-cases/good/full/bare-tunable-values-with-schema.jsonc +++ b/mlos_bench/mlos_bench/tests/config/schemas/tunable-values/test-cases/good/full/bare-tunable-values-with-schema.jsonc @@ -1,4 +1,6 @@ { + "$schema": "https://raw.githubusercontent.com/microsoft/MLOS/main/mlos_bench/mlos_bench/config/schemas/tunables/tunable-values-schema.json", + "foo": "bar", "int": 1, "float": 1.1, diff --git a/mlos_bench/mlos_bench/tests/config/schemas/tunable-values/test-cases/good/full/empty-tunable-values-with-schema.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/tunable-values/test-cases/good/full/empty-tunable-values-with-schema.jsonc new file mode 100644 index 00000000000..fc43969b4d3 --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/schemas/tunable-values/test-cases/good/full/empty-tunable-values-with-schema.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/MLOS/main/mlos_bench/mlos_bench/config/schemas/tunables/tunable-values-schema.json" + // empty tunable values represents the defaults +} diff --git a/mlos_bench/mlos_bench/tests/config/schemas/tunable-values/test-cases/good/partial/empty-tunable-values-without-schema.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/tunable-values/test-cases/good/partial/empty-tunable-values-without-schema.jsonc new file mode 100644 index 00000000000..c6bd77b9521 --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/schemas/tunable-values/test-cases/good/partial/empty-tunable-values-without-schema.jsonc @@ -0,0 +1,3 @@ +{ + // empty tunable values represents the defaults +} diff --git a/mlos_bench/mlos_bench/tests/tunables/tunables_assign_test.py b/mlos_bench/mlos_bench/tests/tunables/tunables_assign_test.py index 05f29a90646..e4ddbed28be 100644 --- a/mlos_bench/mlos_bench/tests/tunables/tunables_assign_test.py +++ b/mlos_bench/mlos_bench/tests/tunables/tunables_assign_test.py @@ -28,6 +28,32 @@ def test_tunables_assign_unknown_param(tunable_groups: TunableGroups) -> None: ) +def test_tunables_assign_defaults(tunable_groups: TunableGroups) -> None: + """Make sure we can assign the default values using an empty dictionary.""" + tunable_groups_defaults = tunable_groups.copy() + assert tunable_groups.is_defaults() + # Assign the default values. + # Also reset the _is_updated flag to avoid considering that in the comparison. + tunable_groups.assign({}).reset() + assert tunable_groups_defaults == tunable_groups + assert tunable_groups.is_defaults() + new_vm_size = "Standard_B2s" + assert tunable_groups["vmSize"] != new_vm_size + # Change one value. + tunable_groups.assign({"vmSize": new_vm_size}).reset() + assert tunable_groups["vmSize"] == new_vm_size + # Check that the other values are still defaults. + idle_tunable, _ = tunable_groups.get_tunable("idle") + assert idle_tunable.is_default() + assert tunable_groups_defaults != tunable_groups + assert not tunable_groups.is_defaults() + # Reassign defaults. + tunable_groups.assign({}).reset() + assert tunable_groups["vmSize"] != new_vm_size + assert tunable_groups.is_defaults() + assert tunable_groups_defaults == tunable_groups + + def test_tunables_assign_categorical(tunable_categorical: Tunable) -> None: """Regular assignment for categorical tunable.""" # Must be one of: {"Standard_B2s", "Standard_B2ms", "Standard_B4ms"} diff --git a/mlos_bench/mlos_bench/tunables/tunable_groups.py b/mlos_bench/mlos_bench/tunables/tunable_groups.py index da56eb79acc..45d8a02f9c9 100644 --- a/mlos_bench/mlos_bench/tunables/tunable_groups.py +++ b/mlos_bench/mlos_bench/tunables/tunable_groups.py @@ -4,12 +4,15 @@ # """TunableGroups definition.""" import copy +import logging from typing import Dict, Generator, Iterable, Mapping, Optional, Tuple, Union from mlos_bench.config.schemas import ConfigSchema from mlos_bench.tunables.covariant_group import CovariantTunableGroup from mlos_bench.tunables.tunable import Tunable, TunableValue +_LOG = logging.getLogger(__name__) + class TunableGroups: """A collection of covariant groups of tunable parameters.""" @@ -346,11 +349,20 @@ def assign(self, param_values: Mapping[str, TunableValue]) -> "TunableGroups": param_values : Mapping[str, TunableValue] Dictionary mapping Tunable parameter names to new values. + As a special behavior when the mapping is empty the method will restore + the default values rather than no-op. + This allows an empty dictionary in json configs to be used to reset the + tunables to defaults without having to copy the original values from the + tunable_params definition. + Returns ------- self : TunableGroups Self-reference for chaining. """ + if not param_values: + _LOG.info("Empty tunable values set provided. Resetting all tunables to defaults.") + return self.restore_defaults() for key, value in param_values.items(): self[key] = value return self From 610341c2033a584ee9d65087f262a1069f7e52f3 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Mon, 14 Oct 2024 16:25:10 -0500 Subject: [PATCH 21/36] Various quick CI fixups (#870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title Quick fixups to the documentation build. --- ## Description - Expands one of the ignored warnings. - Reformats the sphinx conf for black and pylint. - Removes a warning from that file around redefinition of `html_theme_path`. - Adds a dependency requirement tweak for `pyparsing` when installing under python 3.8 (unrelated other CI bug). - Adds a workaround to a mypy false positive with in checking `np.e` as a "deleted variable" (e.g., `e` is used in a try/except block) --- ## Type of Change - 🛠️ Bug fix - 📝 Documentation update --- ## Testing - Ran `make doc` and `make check-doc`. - Ran `make notebook-exec-test`. --- ## Additional Notes (optional) This is a quick fix to get the CI pipeline working again. The more complete fix to remove warning ignores from doc generation, improve cross reference linking, etc. will be handled later in #869 --- --- Makefile | 2 +- doc/source/conf.py | 53 ++++++++++--------- .../mlos_core/tests/spaces/spaces_test.py | 4 +- mlos_core/setup.py | 3 ++ 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/Makefile b/Makefile index 8eb06a70f83..d41895356ad 100644 --- a/Makefile +++ b/Makefile @@ -757,7 +757,7 @@ build/check-doc.build-stamp: doc/build/html/index.html doc/build/html/htmlcov/in | egrep -C1 -e WARNING -e CRITICAL -e ERROR \ | egrep -v \ -e "warnings.warn\(f'\"{wd.path}\" is shallow and may cause errors'\)" \ - -e "No such file or directory: '.*.examples'.$$" \ + -e "No such file or directory: '.*.examples'.( \[docutils\]\s*)?$$" \ -e 'Problems with "include" directive path:' \ -e 'duplicate object description' \ -e "document isn't included in any toctree" \ diff --git a/doc/source/conf.py b/doc/source/conf.py index a06436ba1f1..42459f4de92 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -2,6 +2,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # +"""Sphinx configuration for MLOS documentation.""" +# pylint: disable=invalid-name # Configuration file for the Sphinx documentation builder. # @@ -21,30 +23,31 @@ from logging import warning -import sphinx_rtd_theme +import sphinx_rtd_theme # pylint: disable=unused-import -sys.path.insert(0, os.path.abspath('../../mlos_core/mlos_core')) -sys.path.insert(1, os.path.abspath('../../mlos_bench/mlos_bench')) -sys.path.insert(1, os.path.abspath('../../mlos_viz/mlos_viz')) +sys.path.insert(0, os.path.abspath("../../mlos_core/mlos_core")) +sys.path.insert(1, os.path.abspath("../../mlos_bench/mlos_bench")) +sys.path.insert(1, os.path.abspath("../../mlos_viz/mlos_viz")) # -- Project information ----------------------------------------------------- -project = 'MLOS' -copyright = '2024, Microsoft GSL' -author = 'Microsoft GSL' +project = "MLOS" +copyright = "2024, Microsoft GSL" # pylint: disable=redefined-builtin +author = "Microsoft GSL" # The full version, including alpha/beta/rc tags try: from version import VERSION except ImportError: - VERSION = '0.0.1-dev' + VERSION = "0.0.1-dev" warning(f"version.py not found, using dummy VERSION={VERSION}") try: from setuptools_scm import get_version - version = get_version(root='../..', relative_to=__file__, fallback_version=VERSION) + + version = get_version(root="../..", relative_to=__file__, fallback_version=VERSION) if version is not None: VERSION = version except ImportError: @@ -60,25 +63,25 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'nbsphinx', - 'sphinx.ext.autodoc', - 'sphinx.ext.autosummary', - 'sphinx.ext.doctest', + "nbsphinx", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.doctest", # 'sphinx.ext.intersphinx', # 'sphinx.ext.linkcode', - 'numpydoc', - 'matplotlib.sphinxext.plot_directive', - 'myst_parser', + "numpydoc", + "matplotlib.sphinxext.plot_directive", + "myst_parser", ] source_suffix = { - '.rst': 'restructuredtext', + ".rst": "restructuredtext", # '.txt': 'markdown', - '.md': 'markdown', + ".md": "markdown", } # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # generate autosummary even if no references autosummary_generate = True @@ -100,7 +103,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', '_templates'] +exclude_patterns = ["_build", "_templates"] # -- Options for HTML output ------------------------------------------------- @@ -108,16 +111,16 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +html_theme = "sphinx_rtd_theme" +# html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # -- nbsphinx options for rendering notebooks ------------------------------- # nbsphinx_execute = 'never' # enable to stop nbsphinx from executing notebooks -nbsphinx_kernel_name = 'python3' +nbsphinx_kernel_name = "python3" # Exclude build directory and Jupyter backup files: -exclude_patterns = ['_build', '**.ipynb_checkpoints'] +exclude_patterns = ["_build", "**.ipynb_checkpoints"] diff --git a/mlos_core/mlos_core/tests/spaces/spaces_test.py b/mlos_core/mlos_core/tests/spaces/spaces_test.py index 479f3980880..b98d40d6270 100644 --- a/mlos_core/mlos_core/tests/spaces/spaces_test.py +++ b/mlos_core/mlos_core/tests/spaces/spaces_test.py @@ -26,6 +26,8 @@ OptimizerSpace = Union[FlamlSpace, CS.ConfigurationSpace] OptimizerParam = Union[FlamlDomain, Hyperparameter] +NP_E: float = np.e # type: ignore[misc] # false positive (read deleted variable) + def assert_is_uniform(arr: npt.NDArray) -> None: """Implements a few tests for uniformity.""" @@ -44,7 +46,7 @@ def assert_is_uniform(arr: npt.NDArray) -> None: assert f_p_value > 0.5 -def assert_is_log_uniform(arr: npt.NDArray, base: float = np.e) -> None: +def assert_is_log_uniform(arr: npt.NDArray, base: float = NP_E) -> None: """Checks whether an array is log uniformly distributed.""" logs = np.log(arr) / np.log(base) assert_is_uniform(logs) diff --git a/mlos_core/setup.py b/mlos_core/setup.py index 34a0032631a..b2a711fc0d6 100644 --- a/mlos_core/setup.py +++ b/mlos_core/setup.py @@ -97,6 +97,9 @@ def _get_long_desc_from_readme(base_url: str) -> dict: 'pandas >= 2.2.0;python_version>="3.9"', 'pandas >= 1.0.3;python_version<"3.9"', "ConfigSpace>=1.0", + # pyparsing (required by matplotlib and needed for the notebook examples) + # 3.2 has incompatibilities with python 3.8 due to type hints + 'pyparsing<3.2; python_version<"3.9"', ], extras_require=extra_requires, **_get_long_desc_from_readme("https://github.com/microsoft/MLOS/tree/main/mlos_core"), From ed7a6146eb8deb38a32349e0625fbc0d3da8d45c Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Mon, 14 Oct 2024 16:42:42 -0500 Subject: [PATCH 22/36] Update container purge docs (#871) # Pull Request ## Title Updates some documentation on the `acr purge` task used to keep storage retention reasonable for devcontainer images. --- --- .devcontainer/scripts/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/scripts/README.md b/.devcontainer/scripts/README.md index 78df5484b66..beebe2db6e2 100644 --- a/.devcontainer/scripts/README.md +++ b/.devcontainer/scripts/README.md @@ -31,7 +31,7 @@ To save space in the ACR, we purge images older than 7 days. ```sh #DRY_RUN_ARGS='--dry-run' -PURGE_CMD="acr purge --filter 'devcontainer-cli:.*' --filter 'mlos-devcontainer:.*' --untagged --ago 7d $DRY_RUN_ARGS" +PURGE_CMD="acr purge --filter 'devcontainer-cli:.*' --filter 'mlos-devcontainer:.*' --untagged --ago 30d --keep 3 $DRY_RUN_ARGS" # Setup a daily task: az acr task create --name dailyPurgeTask --cmd "$PURGE_CMD" --registry mloscore --schedule "0 1 * * *" --context /dev/null From 50a0e0fd33cbd3af9015241ef9ffb6e9b04f88bd Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Tue, 15 Oct 2024 15:36:37 -0500 Subject: [PATCH 23/36] Add publication links (#872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title Adds a set of publication links to the front page readme. --- ## Type of Change - 📝 Documentation update --- --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index fd3eef787bf..31cc6da0213 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ MLOS is a project to enable autotuning for systems. - [Installation](#installation) - [See Also](#see-also) - [Examples](#examples) + - [Publications](#publications) @@ -187,3 +188,10 @@ Details on using a local version from git are available in [CONTRIBUTING.md](./C Working example of tuning `sqlite` with MLOS. These can be used as starting points for new autotuning projects. + +### Publications + +- [MLOS in Action: Bridging the Gap Between Experimentation and Auto-Tuning in the Cloud](https://www.vldb.org/pvldb/vol17/p4269-kroth.pdf) at [VLDB 2024](https://www.vldb.org/2024/?papers-demo) +- [Towards Building Autonomous Data Services on Azure](https://dl.acm.org/doi/abs/10.1145/3555041.3589674) in [SIGMOD Companion 2023](https://dl.acm.org/doi/proceedings/10.1145/3555041) +- [LlamaTune: Sample-efficient DBMS configuration tuning](https://www.microsoft.com/en-us/research/publication/llamatune-sample-efficient-dbms-configuration-tuning) at [VLDB 2022](https://vldb.org/pvldb/volumes/15/) +- [MLOS: An infrastructure for automated software performance engineering](https://arxiv.org/abs/2006.02155) at [DEEM 2020](https://deem-workshop.github.io/2020/index.html) From d2738e70e5ccf3ef8115d05a016058c907bece31 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Tue, 15 Oct 2024 15:57:56 -0500 Subject: [PATCH 24/36] Simplify devcontainer (#864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title Combines container build steps to reduce devcontainer size substantially. --- ## Description Reduces container size from ~5.7GB to ~2.7GB by combining the following steps: - install conda (previously in a base image layer) - create the base mlos environment This reduces the space substantially because conda's usual attempt to do hardlinks across environments is actually able to work. The downside is that changes to base package level requirements require reinstalling all of conda again (i.e., the single large combined container layer is less cacheable). Should help with issues like: https://github.com/Microsoft-CISL/sqlite-autotuning/issues/7 --- ## Type of Change - 🔄 Refactor - Dev environment --- ## Testing - `time make devcontainer` - "Rebuild devcontainer" inside VSCode - `docker image ls` to check the sizes --- --------- Co-authored-by: Sergiy Matusevych --- .devcontainer/Dockerfile | 115 +++++++++++++++++++++------------------ 1 file changed, 61 insertions(+), 54 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8702d314cbc..99b21ede545 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -FROM mcr.microsoft.com/devcontainers/miniconda:3 AS base +FROM mcr.microsoft.com/vscode/devcontainers/base AS base # Add some additional packages for the devcontainer terminal environment. USER root @@ -9,19 +9,35 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends \ bash bash-completion \ less colordiff \ - curl jq \ + curl gpg ca-certificates \ + jq \ ripgrep \ vim-nox neovim python3-pynvim \ make \ rename \ + sudo \ && apt-get clean && rm -rf /var/lib/apt/lists/* \ && echo "C-w: unix-filename-rubout" >> /etc/inputrc # Also tweak C-w to stop at slashes as well instead of just spaces +# Prepare the mlos_deps.yml file in a cross platform way. +FROM mcr.microsoft.com/vscode/devcontainers/base AS deps-prep +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends \ + python3-minimal python3-setuptools +COPY --chown=vscode . /tmp/conda-tmp/ +RUN /tmp/conda-tmp/prep-deps-files.sh \ + && ls -l /tmp/conda-tmp/ # && cat /tmp/conda-tmp/combined.requirements.txt /tmp/conda-tmp/mlos_deps.yml + +FROM base AS conda + # Set some cache dirs to be owned by the vscode user even as we're currently # executing as root to build the container image. # NOTE: We do *not* mark these as volumes - it doesn't help rebuilding at all. +RUN addgroup conda \ + && adduser vscode conda + ARG PIP_CACHE_DIR=/var/cache/pip ENV PIP_CACHE_DIR=/var/cache/pip RUN mkdir -p ${PIP_CACHE_DIR} \ @@ -36,66 +52,57 @@ RUN mkdir -p ${CONDA_PKGS_DIRS} \ USER vscode:conda -# Upgrade conda and use strict priorities -# Use the mamba solver (necessary for some quality of life speedups due to -# required packages to support Windows) -RUN umask 0002 \ +# Try and prime the devcontainer's ssh known_hosts keys with the github one for scripted calls. +RUN mkdir -p /home/vscode/.ssh \ + && ( \ + grep -q ^github.com /home/vscode/.ssh/known_hosts \ + || ssh-keyscan github.com | tee -a /home/vscode/.ssh/known_hosts \ + ) + +COPY --from=deps-prep --chown=vscode:conda /tmp/conda-tmp/mlos_deps.yml /tmp/conda-tmp/combined.requirements.txt /tmp/conda-tmp/ + +# Combine the installation of miniconda and the mlos dependencies into a single step in order to save space. +# This allows the mlos env to reference the base env's packages without duplication across layers. +RUN echo "Setup miniconda" \ + && curl -Ss https://repo.anaconda.com/pkgs/misc/gpgkeys/anaconda.asc | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/conda.gpg > /dev/null \ + && gpg --keyring /etc/apt/trusted.gpg.d/conda.gpg --no-default-keyring --fingerprint 34161F5BF5EB1D4BFBBB8F0A8AEB4F8B29D82806 \ + && echo "deb [arch=amd64 signed-by=/etc/apt/trusted.gpg.d/conda.gpg] https://repo.anaconda.com/pkgs/misc/debrepo/conda stable main" | sudo tee /etc/apt/sources.list.d/conda.list \ + && sudo apt-get update \ + && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends conda \ + && sudo apt-get clean && sudo rm -rf /var/lib/apt/lists/* \ + && echo "# Adjust the conda installation to be user/group writable." \ + && sudo /opt/conda/bin/conda init --system \ + && sudo chgrp -R conda /opt/conda \ + && sudo chmod -R g+wX /opt/conda \ + && find /opt/conda -type d -print0 | xargs -0 sudo chmod -c g+s \ + && umask 0002 \ + && echo "# Use conda-forge first to get the latest versions of packages " \ + && echo "# and reduce duplication with mlos env (which also uses conda-forge first)." \ + && echo "# Upgrade conda and use strict priorities" \ + && echo "# Use the mamba solver (necessary for some quality of life speedups due to required packages to support Windows)" \ + && /opt/conda/bin/conda init \ && /opt/conda/bin/conda config --set channel_priority strict \ && /opt/conda/bin/conda info \ - && /opt/conda/bin/conda update -v -y -n base -c defaults --all \ + && /opt/conda/bin/conda update -v -y -n base -c conda-forge -c defaults --all \ && /opt/conda/bin/conda list -n base \ - && /opt/conda/bin/conda install -v -y -n base conda-libmamba-solver \ - && /opt/conda/bin/conda config --set solver libmamba \ + && /opt/conda/bin/conda install -v -y -n base -c conda-forge -c defaults conda-libmamba-solver \ + && /opt/conda/bin/conda config --system --set solver libmamba \ + && echo "# Install some additional editor packages for the base environment." \ + && /opt/conda/bin/conda run -n base pip install --no-cache-dir -U pynvim \ + && echo "# Clean up conda cache to save some space." \ && /opt/conda/bin/conda list -n base \ && /opt/conda/bin/conda clean -v -y -a \ - && /opt/conda/bin/conda run -n base pip cache purge - -# No longer relevant since we're using conda-forge in the environment files by default now. -## Update the base. This helps save space by making sure the same version -## python is used for both the base env and mlos env. -#RUN umask 0002 \ -# && /opt/conda/bin/conda update -v -y -n base -c defaults --all \ -# && /opt/conda/bin/conda update -v -y -n base -c defaults conda python \ -# && /opt/conda/bin/conda clean -v -y -a \ -# && /opt/conda/bin/conda run -n base pip cache purge - -# Install some additional editor packages for the base environment. -RUN umask 0002 \ - && /opt/conda/bin/conda run -n base pip install --no-cache-dir -U pynvim - -# Setup (part of) the mlos environment in the devcontainer. -# NOTEs: -# - The mlos_deps.yml file is prepared by the prep-container-build script(s). -# - The rest happens during first container start once the source is available. -# See Also: updateContentCommand in .devcontainer/devcontainer.json -RUN mkdir -p /opt/conda/pkgs/cache/ && chown -R vscode:conda /opt/conda/pkgs/cache/ -RUN /opt/conda/bin/conda init bash \ - && /opt/conda/bin/conda config --set solver libmamba - -# Prepare the mlos_deps.yml file in a cross platform way. -FROM mcr.microsoft.com/devcontainers/miniconda:3 AS deps-prep -COPY --chown=vscode:conda . /tmp/conda-tmp/ -RUN /tmp/conda-tmp/prep-deps-files.sh \ - && ls -l /tmp/conda-tmp/ # && cat /tmp/conda-tmp/combined.requirements.txt /tmp/conda-tmp/mlos_deps.yml - -# Install some additional dependencies for the mlos environment. -# Make sure they have conda group ownership to make the devcontainer more -# reliable useable across vscode uid changes. -FROM base AS devcontainer -USER vscode -COPY --from=deps-prep --chown=vscode:conda /tmp/conda-tmp/mlos_deps.yml /tmp/conda-tmp/combined.requirements.txt /tmp/conda-tmp/ -RUN umask 0002 \ + && /opt/conda/bin/conda run -n base pip cache purge \ + && echo "# Install some additional dependencies for the mlos environment." \ + && echo "# Make sure they have conda group ownership to make the devcontainer more" \ + && echo "# reliable useable across vscode uid changes." \ && sg conda -c "/opt/conda/bin/conda env create -n mlos -v -f /tmp/conda-tmp/mlos_deps.yml" \ && sg conda -c "/opt/conda/bin/conda run -n mlos pip install --no-cache-dir -U -r /tmp/conda-tmp/combined.requirements.txt" \ && sg conda -c "/opt/conda/bin/conda run -n mlos pip cache purge" \ && sg conda -c "/opt/conda/bin/conda clean -v -y -a" \ - && mkdir -p /opt/conda/pkgs/cache/ && chown -R vscode:conda /opt/conda/pkgs/cache/ -RUN mkdir -p /home/vscode/.conda/envs \ + && mkdir -p /opt/conda/pkgs/cache/ && chown -R vscode:conda /opt/conda/pkgs/cache/ \ + && mkdir -p /home/vscode/.conda/envs \ && ln -s /opt/conda/envs/mlos /home/vscode/.conda/envs/mlos -# Try and prime the devcontainer's ssh known_hosts keys with the github one for scripted calls. -RUN mkdir -p /home/vscode/.ssh \ - && ( \ - grep -q ^github.com /home/vscode/.ssh/known_hosts \ - || ssh-keyscan github.com | tee -a /home/vscode/.ssh/known_hosts \ - ) +#ENV PATH=/opt/conda/bin:$PATH +ENV PATH=/opt/conda/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin From 72e10d1833c12b8dd2aa894f1ec36997d2b7d1a7 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Mon, 21 Oct 2024 10:17:37 -0500 Subject: [PATCH 25/36] devcontainer build fixups for macOS clients (#874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title Fixups to the devcontainer build for macOS clients. --- ## Description - Use a more portalable random number generator - Address some differences in docker.sock privileges. - Address differences in `stat` arguments. - Switch to wget mode for conda installation to workaround lack of arm64 apt repo. - When pulling base images to prime cache, use the appropriate architecture (for Windows we only support amd64 for now). - Remove `:latest` from `cache-from` args for `podman` compliance. - Address some differences in `sed` syntax. - Fixes #873 --- ## Type of Change - 🛠️ Bug fix --- ## Testing - local MacBook testing - CI testing for Linux --- ## Additional Notes Doesn't currently do builds for arm64 platform in the pipeline. Can work towards addressing that in the future. --- .devcontainer/Dockerfile | 9 +++----- .../build/build-devcontainer-cli.ps1 | 2 +- .devcontainer/build/build-devcontainer-cli.sh | 14 +++++++++---- .devcontainer/build/build-devcontainer.ps1 | 4 ++-- .devcontainer/build/build-devcontainer.sh | 21 ++++++++++++------- .devcontainer/common.sh | 18 ++++++++++++++++ .devcontainer/scripts/prep-container-build | 5 +++-- .../scripts/prep-container-build.ps1 | 2 +- .devcontainer/scripts/run-devcontainer.sh | 11 +++++++--- .vscode/extensions.json | 1 + Makefile | 2 +- 11 files changed, 62 insertions(+), 27 deletions(-) create mode 100644 .devcontainer/common.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 99b21ede545..63be61bae25 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -64,12 +64,9 @@ COPY --from=deps-prep --chown=vscode:conda /tmp/conda-tmp/mlos_deps.yml /tmp/con # Combine the installation of miniconda and the mlos dependencies into a single step in order to save space. # This allows the mlos env to reference the base env's packages without duplication across layers. RUN echo "Setup miniconda" \ - && curl -Ss https://repo.anaconda.com/pkgs/misc/gpgkeys/anaconda.asc | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/conda.gpg > /dev/null \ - && gpg --keyring /etc/apt/trusted.gpg.d/conda.gpg --no-default-keyring --fingerprint 34161F5BF5EB1D4BFBBB8F0A8AEB4F8B29D82806 \ - && echo "deb [arch=amd64 signed-by=/etc/apt/trusted.gpg.d/conda.gpg] https://repo.anaconda.com/pkgs/misc/debrepo/conda stable main" | sudo tee /etc/apt/sources.list.d/conda.list \ - && sudo apt-get update \ - && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends conda \ - && sudo apt-get clean && sudo rm -rf /var/lib/apt/lists/* \ + && curl -Ss --url https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-$(uname -m).sh -o /tmp/miniconda3.sh \ + && sudo sh /tmp/miniconda3.sh -b -u -p /opt/conda \ + && rm -rf /tmp/miniconda3.sh \ && echo "# Adjust the conda installation to be user/group writable." \ && sudo /opt/conda/bin/conda init --system \ && sudo chgrp -R conda /opt/conda \ diff --git a/.devcontainer/build/build-devcontainer-cli.ps1 b/.devcontainer/build/build-devcontainer-cli.ps1 index f58cd83b22b..7f17e88d5d6 100644 --- a/.devcontainer/build/build-devcontainer-cli.ps1 +++ b/.devcontainer/build/build-devcontainer-cli.ps1 @@ -35,7 +35,7 @@ if ("$env:NO_CACHE" -eq 'true') { else { $cacheFrom = 'mloscore.azurecr.io/devcontainer-cli:latest' $devcontainer_cli_build_args += " --cache-from $cacheFrom" - docker pull $cacheFrom + docker pull --platform linux/amd64 $cacheFrom } $cmd = "docker.exe build -t devcontainer-cli:latest -t cspell:latest " + diff --git a/.devcontainer/build/build-devcontainer-cli.sh b/.devcontainer/build/build-devcontainer-cli.sh index 60bd1ce3337..792dd3975e3 100755 --- a/.devcontainer/build/build-devcontainer-cli.sh +++ b/.devcontainer/build/build-devcontainer-cli.sh @@ -10,20 +10,26 @@ set -eu scriptdir=$(dirname "$(readlink -f "$0")") cd "$scriptdir/" +source ../common.sh + # Build the helper container that has the devcontainer CLI for building the devcontainer. if [ ! -w /var/run/docker.sock ]; then echo "ERROR: $USER does not have write access to /var/run/docker.sock. Please add $USER to the docker group." >&2 exit 1 fi -DOCKER_GID=$(stat -c'%g' /var/run/docker.sock) +DOCKER_GID=$(stat $STAT_FORMAT_GID_ARGS /var/run/docker.sock) # Make this work inside a devcontainer as well. if [ -w /var/run/docker-host.sock ]; then - DOCKER_GID=$(stat -c'%g' /var/run/docker-host.sock) + DOCKER_GID=$(stat $STAT_FORMAT_GID_ARGS /var/run/docker-host.sock) fi export DOCKER_BUILDKIT=${DOCKER_BUILDKIT:-1} + +# TODO: Add multiplatform build support? +#devcontainer_cli_build_args='--platform linux/amd64,linux/arm64' devcontainer_cli_build_args='' + if docker buildx version 2>/dev/null; then devcontainer_cli_build_args+=' --progress=plain' else @@ -33,10 +39,10 @@ fi if [ "${NO_CACHE:-}" == 'true' ]; then devcontainer_cli_build_args+=' --no-cache --pull' else - cacheFrom='mloscore.azurecr.io/devcontainer-cli:latest' + cacheFrom='mloscore.azurecr.io/devcontainer-cli' tmpdir=$(mktemp -d) devcontainer_cli_build_args+=" --cache-from $cacheFrom" - docker --config="$tmpdir" pull "$cacheFrom" || true + docker --config="$tmpdir" pull --platform linux/$(uname -m) "$cacheFrom" || true rmdir "$tmpdir" fi diff --git a/.devcontainer/build/build-devcontainer.ps1 b/.devcontainer/build/build-devcontainer.ps1 index efee08f9e76..83956a98aa5 100644 --- a/.devcontainer/build/build-devcontainer.ps1 +++ b/.devcontainer/build/build-devcontainer.ps1 @@ -42,13 +42,13 @@ if ($null -eq $env:DOCKER_BUILDKIT) { $devcontainer_build_args = '' if ("$env:NO_CACHE" -eq 'true') { $base_image = (Get-Content "$rootdir/.devcontainer/Dockerfile" | Select-String '^FROM' | Select-Object -ExpandProperty Line | ForEach-Object { $_ -replace '^FROM\s+','' } | ForEach-Object { $_ -replace ' AS\s+.*','' } | Select-Object -First 1) - docker pull $base_image + docker pull --platform linux/amd64 $base_image $devcontainer_build_args = '--no-cache' } else { $cacheFrom = 'mloscore.azurecr.io/mlos-devcontainer:latest' $devcontainer_build_args = "--cache-from $cacheFrom" - docker pull "$cacheFrom" + docker pull --platform linux/amd64 "$cacheFrom" } # Make this work inside a devcontainer as well. diff --git a/.devcontainer/build/build-devcontainer.sh b/.devcontainer/build/build-devcontainer.sh index 5aba0ac64fd..aef953d48a3 100755 --- a/.devcontainer/build/build-devcontainer.sh +++ b/.devcontainer/build/build-devcontainer.sh @@ -12,16 +12,21 @@ repo_root=$(readlink -f "$scriptdir/../..") repo_name=$(basename "$repo_root") cd "$scriptdir/" +source ../common.sh + DEVCONTAINER_IMAGE="devcontainer-cli:latest" MLOS_AUTOTUNING_IMAGE="mlos-devcontainer:latest" # Build the helper container that has the devcontainer CLI for building the devcontainer. NO_CACHE=${NO_CACHE:-} ./build-devcontainer-cli.sh -DOCKER_GID=$(stat -c'%g' /var/run/docker.sock) +DOCKER_GID=$(stat $STAT_FORMAT_GID_ARGS /var/run/docker.sock) # Make this work inside a devcontainer as well. if [ -w /var/run/docker-host.sock ]; then - DOCKER_GID=$(stat -c'%g' /var/run/docker-host.sock) + DOCKER_GID=$(stat $STAT_FORMAT_GID_ARGS /var/run/docker-host.sock) +fi +if [[ $OSTYPE =~ darwin* ]]; then + DOCKER_GID=0 fi # Build the devcontainer image. @@ -30,7 +35,7 @@ rootdir="$repo_root" # Run the initialize command on the host first. # Note: command should already pull the cached image if possible. pwd -devcontainer_json=$(cat "$rootdir/.devcontainer/devcontainer.json" | sed -e 's|^\s*//.*||' -e 's|/\*|\n&|g;s|*/|&\n|g' | sed -e '/\/\*/,/*\//d') +devcontainer_json=$(cat "$rootdir/.devcontainer/devcontainer.json" | sed -e 's|^[ \t]*//.*||' -e 's|/\*|\n&|g;s|*/|&\n|g' | sed -e '/\/\*/,/*\//d') initializeCommand=$(echo "$devcontainer_json" | docker run -i --rm $DEVCONTAINER_IMAGE jq -e -r '.initializeCommand[]') if [ -z "$initializeCommand" ]; then echo "No initializeCommand found in devcontainer.json" >&2 @@ -39,16 +44,18 @@ else eval "pushd "$rootdir/"; $initializeCommand; popd" fi +# TODO: Add multi-platform build support? +#devcontainer_build_args='--platform linux/amd64,linux/arm64' devcontainer_build_args='' if [ "${NO_CACHE:-}" == 'true' ]; then base_image=$(grep '^FROM ' "$rootdir/.devcontainer/Dockerfile" | sed -e 's/^FROM //' -e 's/ AS .*//' | head -n1) - docker pull "$base_image" || true + docker pull --platform linux/$(uname -m) "$base_image" || true devcontainer_build_args='--no-cache' else - cache_from='mloscore.azurecr.io/mlos-devcontainer:latest' - devcontainer_build_args="--cache-from $cache_from --cache-from mlos-devcontainer:latest" + cache_from='mloscore.azurecr.io/mlos-devcontainer' + devcontainer_build_args="--cache-from $cache_from --cache-from mlos-devcontainer" tmpdir=$(mktemp -d) - docker --config="$tmpdir" pull "$cache_from" || true + docker --config="$tmpdir" pull --platform linux/$(uname -m) "$cache_from" || true rmdir "$tmpdir" fi diff --git a/.devcontainer/common.sh b/.devcontainer/common.sh new file mode 100644 index 00000000000..101c89a2151 --- /dev/null +++ b/.devcontainer/common.sh @@ -0,0 +1,18 @@ +## +## Copyright (c) Microsoft Corporation. +## Licensed under the MIT License. +## +case $OSTYPE in + linux*) + STAT_FORMAT_GID_ARGS="-c%g" + STAT_FORMAT_INODE_ARGS="-c%i" + ;; + darwin*) + STAT_FORMAT_GID_ARGS="-f%g" + STAT_FORMAT_INODE_ARGS="-f%i" + ;; + *) + echo "ERROR: Unhandled OSTYPE: $OSTYPE" + exit 1 + ;; +esac diff --git a/.devcontainer/scripts/prep-container-build b/.devcontainer/scripts/prep-container-build index dfdcc75eeb2..80d0a9f84c3 100755 --- a/.devcontainer/scripts/prep-container-build +++ b/.devcontainer/scripts/prep-container-build @@ -20,7 +20,8 @@ if [ ! -f .env ]; then fi # Also prep the random NGINX_PORT for the docker-compose command. if ! [ -e .devcontainer/.env ] || ! egrep -q "^NGINX_PORT=[0-9]+$" .devcontainer/.env; then - NGINX_PORT=$(($(shuf -i 0-30000 -n 1) + 80)) + RANDOM=$$ + NGINX_PORT=$((($RANDOM % 30000) + 1 + 80)) echo "NGINX_PORT=$NGINX_PORT" > .devcontainer/.env fi @@ -55,6 +56,6 @@ if [ "${NO_CACHE:-}" != 'true' ]; then ## Make sure we use an empty config to avoid auth issues for devs with the ## registry, which should allow anonymous pulls #tmpdir=$(mktemp -d) - #docker --config="$tmpdir" pull -q "$cacheFrom" >/dev/null || true + #docker --config="$tmpdir" pull --platform linux/$(uname -m) -q "$cacheFrom" >/dev/null || true #rmdir "$tmpdir" fi diff --git a/.devcontainer/scripts/prep-container-build.ps1 b/.devcontainer/scripts/prep-container-build.ps1 index 2ed7183ccf3..9b1d6bd42c0 100644 --- a/.devcontainer/scripts/prep-container-build.ps1 +++ b/.devcontainer/scripts/prep-container-build.ps1 @@ -49,5 +49,5 @@ if ($env:NO_CACHE -ne 'true') { $cacheFrom = 'mloscore.azurecr.io/mlos-devcontainer' # Skip pulling for now (see TODO note above) Write-Host "Consider pulling image $cacheFrom for build caching." - #docker pull $cacheFrom + #docker pull --platform linux/amd64 $cacheFrom } diff --git a/.devcontainer/scripts/run-devcontainer.sh b/.devcontainer/scripts/run-devcontainer.sh index 2d8be436516..dfd7d3f65d6 100755 --- a/.devcontainer/scripts/run-devcontainer.sh +++ b/.devcontainer/scripts/run-devcontainer.sh @@ -17,15 +17,20 @@ repo_root=$(readlink -f "$scriptdir/../..") repo_name=$(basename "$repo_root") cd "$repo_root" -container_name="$repo_name.$(stat -c%i "$repo_root/")" +source .devcontainer/common.sh + +container_name="$repo_name.$(stat $STAT_FORMAT_INODE_ARGS "$repo_root/")" # Be sure to use the host workspace folder if available. workspace_root=${LOCAL_WORKSPACE_FOLDER:-$repo_root} if [ -e /var/run/docker-host.sock ]; then - docker_gid=$(stat -c%g /var/run/docker-host.sock) + docker_gid=$(stat $STAT_FORMAT_GID_ARGS /var/run/docker-host.sock) else - docker_gid=$(stat -c%g /var/run/docker.sock) + docker_gid=$(stat $STAT_FORMAT_GID_ARGS /var/run/docker.sock) +fi +if [[ $OSTYPE =~ darwin* ]]; then + docker_gid=0 fi set -x diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 76dce33d5af..615c1a7e4a4 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -19,6 +19,7 @@ "ms-python.pylint", "ms-python.python", "ms-python.vscode-pylance", + "ms-vscode-remote.remote-containers", "ms-vsliveshare.vsliveshare", "njpwerner.autodocstring", "redhat.vscode-yaml", diff --git a/Makefile b/Makefile index d41895356ad..672a1ce7c22 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ MKDIR_BUILD := $(shell test -d build || mkdir build) #CONDA_INFO_LEVEL ?= -q # Run make in parallel by default. -MAKEFLAGS += -j$(shell nproc) +MAKEFLAGS += -j$(shell nproc 2>/dev/null || sysctl -n hw.ncpu) #MAKEFLAGS += -Oline .PHONY: all From 0055a49fada9597d0b53f1985dde44830ad334ee Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Thu, 31 Oct 2024 13:46:43 -0500 Subject: [PATCH 26/36] Add support for Python 3.13 and mark is as the expected default (#879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title Add support for Python 3.13 and mark is as the expected default. --- ## Description Upstream changed the default version of python, so this addresses changes in checks in the pipelines. So far it seems to "just work (tm)" --- ## Type of Change - 🛠️ Bug fix - ✨ New feature - 🧪 Tests --- ## Testing Usual CI tests. --- --- .github/workflows/devcontainer.yml | 5 +-- .github/workflows/linux.yml | 9 ++++++ conda-envs/mlos-3.13.yml | 49 ++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 conda-envs/mlos-3.13.yml diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index 82d230543d5..1db506ce66a 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -142,8 +142,9 @@ jobs: timeout-minutes: 2 run: | set -x - docker exec --user vscode --env USER=vscode mlos-devcontainer conda run -n mlos python -c \ - 'from sys import version_info as vers; assert (vers.major, vers.minor) == (3, 12), f"Unexpected python version: {vers}"' + docker exec --user vscode --env USER=vscode mlos-devcontainer \ + conda run -n mlos python -c \ + 'from sys import version_info as vers; assert (vers.major, vers.minor) == (3, 13), f"Unexpected python version: {vers}"' - name: Check for missing licenseheaders timeout-minutes: 3 diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index c024a21e1b6..7e280901875 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -38,6 +38,7 @@ jobs: - "3.10" - "3.11" - "3.12" + - "3.13" env: cache_cur_date: unset @@ -136,6 +137,14 @@ jobs: conda run -n $CONDA_ENV_NAME pip cache dir conda run -n $CONDA_ENV_NAME pip cache info + - name: Verify expected version of python in conda env + if: ${{ matrix.python_version == '' }} + timeout-minutes: 2 + run: | + set -x + conda run -n mlos python -c \ + 'from sys import version_info as vers; assert (vers.major, vers.minor) == (3, 13), f"Unexpected python version: {vers}"' + # This is moreso about code cleanliness, which is a dev thing, not a # functionality thing, and the rules for that change between python versions, # so only do this for the default in the devcontainer. diff --git a/conda-envs/mlos-3.13.yml b/conda-envs/mlos-3.13.yml new file mode 100644 index 00000000000..baa37e257e4 --- /dev/null +++ b/conda-envs/mlos-3.13.yml @@ -0,0 +1,49 @@ +name: mlos-3.13 +channels: + # Use conda-forge to allow other packages to install with python 3.12. + # See Also: https://github.com/microsoft/MLOS/issues/832 + - conda-forge + - defaults +dependencies: + # Basic dev environment packages. + # All other dependencies for the mlos modules come from pip. + - pip + - pylint + - black + - pycodestyle + - pydocstyle + - flake8 + - python-build + - jupyter + - ipykernel + - nb_conda_kernels + - matplotlib-base + - seaborn + - pandas + - pyarrow + - swig + # FIXME: Temporarily avoid broken libpq that's missing client headers. + - libpq<17.0 + - python=3.13 + # See comments in mlos.yml. + #- gcc_linux-64 + - pip: + - bump2version + - check-jsonschema + - isort + - docformatter + - licenseheaders + - mypy + - pandas-stubs + - types-beautifulsoup4 + - types-colorama + - types-jsonschema + - types-pygments + - types-requests + - types-setuptools + # Workaround a pylance issue in vscode that prevents it finding the latest + # method of pip installing editable modules. + # https://github.com/microsoft/pylance-release/issues/3473 + - "--config-settings editable_mode=compat --editable ../mlos_core[full-tests]" + - "--config-settings editable_mode=compat --editable ../mlos_bench[full-tests]" + - "--config-settings editable_mode=compat --editable ../mlos_viz[full-tests]" From 4a998b338f6f1f9de7f7188480557cda47472988 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Thu, 31 Oct 2024 14:04:08 -0500 Subject: [PATCH 27/36] Some small update notes to contributing (#877) --- CONTRIBUTING.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2dec110876d..c32eaa836ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,10 +75,11 @@ We expect development to follow a typical "forking" style workflow: The easiest way to do this is to run the `make` commands that are also used in the CI pipeline: ```shell - # All at once. + # All at once in parallel. make all # Or individually (for easier debugging) + make format make check make test make dist-test @@ -87,7 +88,11 @@ We expect development to follow a typical "forking" style workflow: 1. Submit changes for inclusion as a [Pull Request on Github](https://github.com/microsoft/MLOS/pulls). - > Please try to keep PRs small whenver possible and don't include unnecessaary formatting changes. + Some notes on organizing changes to help reviewers: + + 1. Please try to keep PRs small whenver possible and don't include unnecessaary formatting changes. + 1. Larger changes can be planned in [Issues](https://github.com/microsoft/MLOS/issues), prototyped in a large draft PR for early feedback, and split into smaller PRs via discussion. + 1. All changes should include test coverage (either new or existing). 1. PRs are associated with [Github Issues](https://github.com/microsoft/MLOS/issues) and need [MLOS-committers](https://github.com/orgs/microsoft/teams/MLOS-committers) to sign-off (in addition to other CI pipeline checks like tests and lint checks to pass). 1. Once approved, the PR can be completed using a squash merge in order to keep a nice linear history. From 5aeb4528c9c246b4fb73f56a5f10be5bcd08bd77 Mon Sep 17 00:00:00 2001 From: Sergiy Matusevych Date: Fri, 1 Nov 2024 13:57:45 -0700 Subject: [PATCH 28/36] A test configuration for the environment based on local shell scripts (#878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title A test configuration for the environment based on local shell scripts --- ## Description This is a sample MLOS configuration (CLI config, tunables, environment, and the scripts) that launches a local environment and invokes local shell scripts to set it up and run trials. --- ## Type of Change This is purely a config-based update. We might add unit tests around that setup later. - ✨ New feature - 📝 Documentation update - 🧪 Tests --- ## Testing Unit tests that validate the environment and run test experiments are included in this PR. To test the environment manually, run: ```bash mlos_bench \ --config mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-bench.jsonc \ --globals experiment_test_local.jsonc \ --tunable_values tunable-values/tunable-values-local.jsonc ``` --- ## Additional Notes (optional) This setup is supposed to serve as an example of other local environments with shell scripts. --- --- .../config/cli/test-cli-local-env-bench.jsonc | 37 +++++++++ .../config/cli/test-cli-local-env-opt.jsonc | 37 +++++++++ .../environments/local/scripts/bench_run.py | 63 ++++++++++++++ .../environments/local/scripts/bench_setup.py | 62 ++++++++++++++ .../local/test_local-tunables.jsonc | 13 +++ .../environments/local/test_local_env.jsonc | 82 +++++++++++++++++++ .../experiments/experiment_test_local.jsonc | 24 ++++++ .../tunable-values/tunable-values-local.jsonc | 8 ++ .../tests/launcher_in_process_test.py | 22 +++++ 9 files changed, 348 insertions(+) create mode 100644 mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-bench.jsonc create mode 100644 mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-opt.jsonc create mode 100644 mlos_bench/mlos_bench/tests/config/environments/local/scripts/bench_run.py create mode 100644 mlos_bench/mlos_bench/tests/config/environments/local/scripts/bench_setup.py create mode 100644 mlos_bench/mlos_bench/tests/config/environments/local/test_local-tunables.jsonc create mode 100644 mlos_bench/mlos_bench/tests/config/environments/local/test_local_env.jsonc create mode 100644 mlos_bench/mlos_bench/tests/config/experiments/experiment_test_local.jsonc create mode 100644 mlos_bench/mlos_bench/tests/config/tunable-values/tunable-values-local.jsonc diff --git a/mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-bench.jsonc b/mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-bench.jsonc new file mode 100644 index 00000000000..6a7ae43c1d7 --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-bench.jsonc @@ -0,0 +1,37 @@ +// +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// A test config to launch a local shell environment with some tunables. +// +// Run: +// mlos_bench \ +// --config mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-bench.jsonc \ +// --globals experiment_test_local.jsonc \ +// --tunable_values tunable-values/tunable-values-local.jsonc +{ + "config_path": [ + "mlos_bench/mlos_bench/config", + "mlos_bench/mlos_bench/tests/config/experiments", + "mlos_bench/mlos_bench/tests/config" + ], + + // Include some sensitive parameters that should not be checked in (`shell_password`). + // Alternatively, one can specify this file through the --globals CLI option. + // "globals": [ + // "test_local_private_params.jsonc" + // ], + + "environment": "environments/local/test_local_env.jsonc", + + // If optimizer is not specified, run a single benchmark trial. + // "optimizer": "optimizers/mlos_core_default_opt.jsonc", + + // If storage is not specified, just print the results to the log. + // "storage": "storage/sqlite.jsonc", + + "teardown": false, + + "log_file": "test-local-bench.log", + "log_level": "DEBUG" // "INFO" for less verbosity +} diff --git a/mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-opt.jsonc b/mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-opt.jsonc new file mode 100644 index 00000000000..e3c0cfcdc09 --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-opt.jsonc @@ -0,0 +1,37 @@ +// +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// A test config to launch a local shell environment with some tunables. +// +// Run: +// mlos_bench \ +// --config mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-opt.jsonc \ +// --globals experiment_test_local.jsonc \ +// --max_suggestions 10 +{ + "config_path": [ + "mlos_bench/mlos_bench/config", + "mlos_bench/mlos_bench/tests/config/experiments", + "mlos_bench/mlos_bench/tests/config" + ], + + // Include some sensitive parameters that should not be checked in (`shell_password`). + // Alternatively, one can specify this file through the --globals CLI option. + // "globals": [ + // "test_local_private_params.jsonc" + // ], + + "environment": "environments/local/test_local_env.jsonc", + + // If optimizer is not specified, run a single benchmark trial. + "optimizer": "optimizers/mlos_core_default_opt.jsonc", + + // If storage is not specified, just print the results to the log. + // "storage": "storage/sqlite.jsonc", + + "teardown": false, + + "log_file": "test-local-bench.log", + "log_level": "DEBUG" // "INFO" for less verbosity +} diff --git a/mlos_bench/mlos_bench/tests/config/environments/local/scripts/bench_run.py b/mlos_bench/mlos_bench/tests/config/environments/local/scripts/bench_run.py new file mode 100644 index 00000000000..541c4dacea5 --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/environments/local/scripts/bench_run.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +""" +Helper script to run the benchmark and store the results and telemetry in CSV files. + +This is a sample script that demonstrates how to produce the benchmark results +and telemetry in the format that MLOS expects. + +THIS IS A TOY EXAMPLE. The script does not run any actual benchmarks and produces fake +data for demonstration purposes. Please copy and extend it to suit your needs. + +Run: + ./bench_run.py ./output-metrics.csv ./output-telemetry.csv` +""" + +import argparse +from datetime import datetime, timedelta + +import pandas + + +def _main(output_metrics: str, output_telemetry: str) -> None: + + # Some fake const data that we can check in the unit tests. + # Our unit tests expect the `score` metric to be present in the output. + df_metrics = pandas.DataFrame( + [ + {"metric": "score", "value": 123.4}, # A copy of `total_time` + {"metric": "total_time", "value": 123.4}, + {"metric": "latency", "value": 9.876}, + {"metric": "throughput", "value": 1234567}, + ] + ) + df_metrics.to_csv(output_metrics, index=False) + + # Timestamps are const so we can check them in the tests. + timestamp = datetime(2024, 10, 25, 13, 45) + ts_delta = timedelta(seconds=30) + + df_telemetry = pandas.DataFrame( + [ + {"timestamp": timestamp, "metric": "cpu_load", "value": 0.1}, + {"timestamp": timestamp, "metric": "mem_usage", "value": 20.0}, + {"timestamp": timestamp + ts_delta, "metric": "cpu_load", "value": 0.6}, + {"timestamp": timestamp + ts_delta, "metric": "mem_usage", "value": 33.0}, + {"timestamp": timestamp + 2 * ts_delta, "metric": "cpu_load", "value": 0.5}, + {"timestamp": timestamp + 2 * ts_delta, "metric": "mem_usage", "value": 31.0}, + ] + ) + df_telemetry.to_csv(output_telemetry, index=False) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Run the benchmark and save the results in CSV files." + ) + parser.add_argument("output_metrics", help="CSV file to save the benchmark results to.") + parser.add_argument("output_telemetry", help="CSV file for telemetry data.") + args = parser.parse_args() + _main(args.output_metrics, args.output_telemetry) diff --git a/mlos_bench/mlos_bench/tests/config/environments/local/scripts/bench_setup.py b/mlos_bench/mlos_bench/tests/config/environments/local/scripts/bench_setup.py new file mode 100644 index 00000000000..e84fa039b7e --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/environments/local/scripts/bench_setup.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +""" +Helper script to update the environment parameters from JSON. + +This is a sample script that demonstrates how to read the tunable parameters +and metadata from JSON and produce some kind of a configuration file for the +application that is being benchmarked or optimized. + +THIS IS A TOY EXAMPLE. The script does not have any actual effect on the system. +Please copy and extend it to suit your needs. + +Run: + `./bench_setup.py ./input-params.json ./input-params-meta.json` +""" + +import argparse +import json +import os + + +def _main(fname_input: str, fname_meta: str, fname_output: str) -> None: + + # In addition to the input JSON files, + # MLOS can pass parameters through the OS environment: + print(f'# RUN: {os.environ["experiment_id"]}:{os.environ["trial_id"]}') + + # Key-value pairs of tunable parameters, e.g., + # {"shared_buffers": "128", ...} + with open(fname_input, "rt", encoding="utf-8") as fh_tunables: + tunables_data = json.load(fh_tunables) + + # Optional free-format metadata for tunable parameters, e.g. + # {"shared_buffers": {"suffix": "MB"}, ...} + with open(fname_meta, "rt", encoding="utf-8") as fh_meta: + tunables_meta = json.load(fh_meta) + + # Pretend that we are generating a PG config file with lines like: + # shared_buffers = 128MB + with open(fname_output, "wt", encoding="utf-8", newline="") as fh_config: + for key, val in tunables_data.items(): + meta = tunables_meta.get(key, {}) + suffix = meta.get("suffix", "") + line = f"{key} = {val}{suffix}" + fh_config.write(line + "\n") + print(line) + + +if __name__ == "__main__": + + parser = argparse.ArgumentParser(description="Update the environment parameters from JSON.") + + parser.add_argument("input", help="JSON file with tunable parameters.") + parser.add_argument("meta", help="JSON file with tunable parameters metadata.") + parser.add_argument("output", help="Output config file.") + + args = parser.parse_args() + + _main(args.input, args.meta, args.output) diff --git a/mlos_bench/mlos_bench/tests/config/environments/local/test_local-tunables.jsonc b/mlos_bench/mlos_bench/tests/config/environments/local/test_local-tunables.jsonc new file mode 100644 index 00000000000..4343fad38ac --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/environments/local/test_local-tunables.jsonc @@ -0,0 +1,13 @@ +{ + "test_local_tunable_group": { + "cost": 1, + "params": { + "shared_buffers": { + "type": "int", + "default": 128, + "range": [1, 1024], + "meta": {"suffix": "MB"} + } + } + } +} diff --git a/mlos_bench/mlos_bench/tests/config/environments/local/test_local_env.jsonc b/mlos_bench/mlos_bench/tests/config/environments/local/test_local_env.jsonc new file mode 100644 index 00000000000..80c754cad8a --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/environments/local/test_local_env.jsonc @@ -0,0 +1,82 @@ +// Test config for test_local_env.py +{ + "class": "mlos_bench.environments.local.LocalEnv", + "name": "Local Shell Test Environment", + + "include_services": [ + // from the built in configs + "services/local/local_exec_service.jsonc" + ], + + // Include the definitions of the tunable parameters to use + // in this environment and its children, if there are any: + "include_tunables": [ + "environments/local/test_local-tunables.jsonc" + ], + + "config": { + + // GROUPS of tunable parameters to use in this environment: + "tunable_params": [ + "test_local_tunable_group" + ], + + "const_args": { + // The actual value should be provided by the user externally + // (e.g., through the --globals file). + // This is just a placeholder to make unit tests work. + "script_password": "PLACEHOLDER" + }, + + // Other non-tunable parameters to use in this environment: + "required_args": [ + "experiment_id", // Specified by the user in `experiment_test_local.jsonc` + "trial_id", // Provided by MLOS/storage + "script_password" // Should be provided by the user (e.g., through --globals). + ], + + // Pass these parameters to the shell script as env variables: + // (can be the names of the tunables, too) + "shell_env_params": [ + "experiment_id", + "trial_id", + "script_password", + "shared_buffers" // This tunable parameter will appear as env var (and in JSON) + ], + + // MLOS will dump key-value pairs of tunable parameters + // into this file in temp directory: + "dump_params_file": "input-params.json", + + // [Optionally] MLOS can dump metadata of tunable parameters here: + "dump_meta_file": "input-params-meta.json", + + // MLOS will create a temp directory, store the parameters and metadata + // into it, and run the setup script from there: + "setup": [ + "echo Set up $experiment_id:$trial_id :: shared_buffers = $shared_buffers", + "environments/local/scripts/bench_setup.py input-params.json input-params-meta.json 99_bench.conf" + ], + + // Run the benchmark script from the temp directory. + "run": [ + "echo Run $experiment_id:$trial_id", + "environments/local/scripts/bench_run.py output-metrics.csv output-telemetry.csv" + ], + + // [Optionally] MLOS can run the teardown script from the temp directory. + // We don't need it here, because it will automatically clean up + // the temp directory after each trial. + "teardown": [ + "echo Tear down $experiment_id:$trial_id" + ], + + // [Optionally] MLOS can read telemetry data produced by the + // `bench_run.py` script: + "read_telemetry_file": "output-telemetry.csv", + + // MLOS will read the results of the benchmark from this file: + // (created by the "run" script) + "read_results_file": "output-metrics.csv" + } +} diff --git a/mlos_bench/mlos_bench/tests/config/experiments/experiment_test_local.jsonc b/mlos_bench/mlos_bench/tests/config/experiments/experiment_test_local.jsonc new file mode 100644 index 00000000000..ef9fd6241ba --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/experiments/experiment_test_local.jsonc @@ -0,0 +1,24 @@ +// Global parameters for the experiment. +{ + "experiment_id": "TEST-LOCAL-001", + + // Add your parameters here. Remember to declare them in the + // experiments' configs in "const_args" and/or "required_args" or + // in the Service or Optimizer "config" section. + + // This parameter gets propagated into the optimizer config. + // By default, MLOS expects the benchmark to produce a single + // scalar, "score". + "optimization_targets": { + "score": "min", // Same as `total_time`, we need it for unit tests. + "total_time": "min", + "throughput": "max" + }, + + // Another parameter that gets propagated into the optimizer config. + // Each such parameter can be overridden by the CLI option, e.g., + // `--max_suggestions 20` + // Number of configurations to be suggested by the optimizer, + // if optimization is enabled. + "max_suggestions": 10 +} diff --git a/mlos_bench/mlos_bench/tests/config/tunable-values/tunable-values-local.jsonc b/mlos_bench/mlos_bench/tests/config/tunable-values/tunable-values-local.jsonc new file mode 100644 index 00000000000..0dfe1dfa72b --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/tunable-values/tunable-values-local.jsonc @@ -0,0 +1,8 @@ +// A simple key-value assignment of an tunables instance. +{ + "$schema": "https://raw.githubusercontent.com/microsoft/MLOS/main/mlos_bench/mlos_bench/config/schemas/tunables/tunable-values-schema.json", + + // Values that are different from the defaults + // in order to test --tunable-values handling in OneShotOptimizer. + "shared_buffers": 256 +} diff --git a/mlos_bench/mlos_bench/tests/launcher_in_process_test.py b/mlos_bench/mlos_bench/tests/launcher_in_process_test.py index eb20b1ababd..787a9eea451 100644 --- a/mlos_bench/mlos_bench/tests/launcher_in_process_test.py +++ b/mlos_bench/mlos_bench/tests/launcher_in_process_test.py @@ -38,6 +38,28 @@ ], 64.53897, ), + ( + [ + "--config", + "mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-bench.jsonc", + "--globals", + "experiment_test_local.jsonc", + "--tunable_values", + "tunable-values/tunable-values-local.jsonc", + ], + 123.4, + ), + ( + [ + "--config", + "mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-opt.jsonc", + "--globals", + "experiment_test_local.jsonc", + "--max-suggestions", + "3", + ], + 123.4, + ), ], ) def test_main_bench(argv: List[str], expected_score: float) -> None: From d50f1f4e8301f47d621f3db535fb4d02303a3fb5 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Tue, 5 Nov 2024 11:24:50 -0600 Subject: [PATCH 29/36] MacOS test fixups and CI pipelines (#876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title Fixes some test issues for Mac OS environments and enables CI pipelines. --- ## Description - [x] mypy fixups - [x] Enable MacOS build pipeline - [x] ~Enable MacOS devcontainer tests~ - [x] ~Needs `docker` enabled (which isn't currently possible)~ - [x] ~Related: add *basic* devcontainer build test and run for Windows as well (also isn't currently possible)~ - [x] Fixes MacOS tests - [x] Address parallel runner issues with output dir cleanup - [x] Fix ssh tests (also a parallelization issue) - [x] ~Publish multi arch docker images (amd64 and arm64) (can't see above)~ Closes #875 --- ## Type of Change - 🛠️ Bug fix - ✨ New feature - 🧪 Tests --- ## Testing Locally tested on MacOS, Windows, and Linux. --- ## Additional Notes (optional) Leaves some currently disabled stub code for creating devcontainers in the future on MacOS and Windows hosts. Unfortunately this isn't currently possible with the Github Action runners. Also did some cosmetic renaming of the CI pipelines for better descriptions. This affects the required tests to pass in the repo settings. Will adjust that after this is approved. --- --- .devcontainer/scripts/prep-container-build | 9 +- .devcontainer/scripts/run-devcontainer.ps1 | 45 ++++ .github/workflows/devcontainer.yml | 11 +- .github/workflows/linux.yml | 4 +- .github/workflows/macos.yml | 218 ++++++++++++++++++ .github/workflows/markdown-link-check.yml | 1 + .github/workflows/windows.yml | 36 ++- Makefile | 2 +- README.md | 1 + .../environments/local/local_env.py | 2 +- .../services/remote/azure/azure_fileshare.py | 2 +- mlos_bench/mlos_bench/storage/sql/common.py | 4 +- mlos_bench/mlos_bench/tests/conftest.py | 14 +- .../optimizers/mlos_core_opt_smac_test.py | 12 +- .../services/remote/ssh/test_ssh_service.py | 2 +- .../tests/services/remote/ssh/up.sh | 2 + 16 files changed, 347 insertions(+), 18 deletions(-) create mode 100644 .devcontainer/scripts/run-devcontainer.ps1 create mode 100644 .github/workflows/macos.yml diff --git a/.devcontainer/scripts/prep-container-build b/.devcontainer/scripts/prep-container-build index 80d0a9f84c3..f6bbb01248e 100755 --- a/.devcontainer/scripts/prep-container-build +++ b/.devcontainer/scripts/prep-container-build @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. @@ -18,9 +18,14 @@ if [ ! -f .env ]; then echo "Creating empty .env file for devcontainer." touch .env fi +# Add some info about the host OS to the .env file. +egrep -v '^HOST_OSTYPE=' .env > .env.tmp || true +echo "HOST_OSTYPE=$OSTYPE" >> .env.tmp +mv .env.tmp .env + # Also prep the random NGINX_PORT for the docker-compose command. if ! [ -e .devcontainer/.env ] || ! egrep -q "^NGINX_PORT=[0-9]+$" .devcontainer/.env; then - RANDOM=$$ + RANDOM=${RANDOM:-$$} NGINX_PORT=$((($RANDOM % 30000) + 1 + 80)) echo "NGINX_PORT=$NGINX_PORT" > .devcontainer/.env fi diff --git a/.devcontainer/scripts/run-devcontainer.ps1 b/.devcontainer/scripts/run-devcontainer.ps1 new file mode 100644 index 00000000000..2e8c490e835 --- /dev/null +++ b/.devcontainer/scripts/run-devcontainer.ps1 @@ -0,0 +1,45 @@ +#!/bin/bash +## +## Copyright (c) Microsoft Corporation. +## Licensed under the MIT License. +## + +# Quick hacky script to start a devcontainer in a non-vscode shell for testing. +# See Also: +# - ../build/build-devcontainer +# - "devcontainer open" subcommand from + +#Set-PSDebug -Trace 2 +$ErrorActionPreference = 'Stop' + +# Move to repo root. +Set-Location "$PSScriptRoot/../.." +$repo_root = (Get-Item . | Select-Object -ExpandProperty FullName) +$repo_name = (Get-Item . | Select-Object -ExpandProperty Name) +$repo_root_id = $repo_root.GetHashCode() +$container_name = "$repo_name.$repo_root_id" + +# Be sure to use the host workspace folder if available. +$workspace_root = $repo_root + +$docker_gid = 0 + +New-Item -Type Directory -ErrorAction Ignore "${env:TMP}/$container_name/dc/shellhistory" + +docker run -it --rm ` + --name "$container_name" ` + --user vscode ` + --env USER=vscode ` + --group-add $docker_gid ` + -v "${env:USERPROFILE}/.azure:/dc/azure" ` + -v "${env:TMP}/$container_name/dc/shellhistory:/dc/shellhistory" ` + -v "/var/run/docker.sock:/var/run/docker.sock" ` + -v "${workspace_root}:/workspaces/$repo_name" ` + --workdir "/workspaces/$repo_name" ` + --env CONTAINER_WORKSPACE_FOLDER="/workspaces/$repo_name" ` + --env LOCAL_WORKSPACE_FOLDER="$workspace_root" ` + --env http_proxy="${env:http_proxy:-}" ` + --env https_proxy="${env:https_proxy:-}" ` + --env no_proxy="${env:no_proxy:-}" ` + mlos-devcontainer ` + $args diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index 1db506ce66a..c0d41a3d72c 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -26,7 +26,9 @@ concurrency: cancel-in-progress: true jobs: - DevContainer: + DevContainerLintBuildTestPublish: + name: DevContainer Lint/Build/Test/Publish + runs-on: ubuntu-latest permissions: @@ -259,9 +261,12 @@ jobs: docker tag mlos-devcontainer:latest ${{ secrets.ACR_LOGINURL }}/mlos-devcontainer:$image_tag docker push ${{ secrets.ACR_LOGINURL }}/mlos-devcontainer:$image_tag - DeployDocs: + + PublishDocs: + name: Publish Documentation + if: github.ref == 'refs/heads/main' - needs: DevContainer + needs: DevContainerLintBuildTestPublish runs-on: ubuntu-latest # Required for github-pages-deploy-action to push to the gh-pages branch. diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 7e280901875..c9ba2bdcaa0 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -19,7 +19,9 @@ concurrency: cancel-in-progress: true jobs: - Linux: + LinuxCondaBuildTest: + name: Linux Build/Test with Conda + runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 00000000000..37bd8e39e40 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,218 @@ +# Note: this file is based on the linux.yml + +name: MLOS MacOS + +on: + workflow_dispatch: + inputs: + tags: + description: Manual MLOS MacOS run + push: + branches: [ main ] + pull_request: + branches: [ main ] + merge_group: + types: [checks_requested] + schedule: + - cron: "1 0 * * *" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: true + +jobs: + MacOSCondaBuildTest: + name: MacOS Build/Test with Conda + + runs-on: macos-latest + + permissions: + contents: read + + # Test multiple versions of python. + strategy: + fail-fast: false + matrix: + python_version: + # Empty string is the floating most recent version of python + # (useful to catch new compatibility issues in nightly builds) + - "" + # For now we only test the latest version of python on MacOS. + #- "3.8" + #- "3.9" + #- "3.10" + #- "3.11" + #- "3.12" + #- "3.13" + + env: + cache_cur_date: unset + cache_cur_hour: unset + cache_prev_hour: unset + CONDA_ENV_NAME: unset + # See notes about $CONDA below. + CONDA_DIR: unset + # When parallel jobs are used, group the output to make debugging easier. + MAKEFLAGS: -Oline + + steps: + - uses: actions/checkout@v4 + + - uses: conda-incubator/setup-miniconda@v3 + + - name: Set cache timestamp variables + id: set_cache_vars + run: | + set -x + if [ -z "${{ matrix.python_version }}" ]; then + CONDA_ENV_NAME=mlos + else + CONDA_ENV_NAME="mlos-${{ matrix.python_version }}" + fi + echo "CONDA_ENV_NAME=$CONDA_ENV_NAME" >> $GITHUB_ENV + echo "cache_cur_date=$(date -u +%Y-%m-%d)" >> $GITHUB_ENV + echo "cache_cur_hour=$(date -u +%H)" >> $GITHUB_ENV + echo "cache_prev_hour=$(date -u -d'1 hour ago' +%H)" >> $GITHUB_ENV + # $CONDA should be set by the setup-miniconda action. + # We set a separate environment variable to allow the dependabot tool + # to parse this file since it expects all env vars to be declared above. + echo "CONDA_DIR=$CONDA" >> $GITHUB_ENV + echo "PIP_CACHE_DIR=$(conda run -n base pip cache dir)" >> $GITHUB_ENV + + #- name: Restore cached conda environment + - name: Restore cached conda packages + id: restore-conda-cache + if: ${{ github.event_name != 'schedule' }} + uses: actions/cache@v4 + with: + #path: ${{ env.CONDA_DIR }}/envs/${{ env.CONDA_ENV_NAME }} + path: ${{ env.CONDA_DIR }}/pkgs + key: conda-${{ runner.os }}-${{ env.CONDA_ENV_NAME }}-${{ hashFiles('conda-envs/${{ env.CONDA_ENV_NAME }}.yml') }}-${{ hashFiles('mlos_*/pyproject.toml') }}-${{ hashFiles('mlos_*/setup.py') }}-${{ env.cache_cur_date }}-${{ env.cache_cur_hour }} + restore-keys: | + conda-${{ runner.os }}-${{ env.CONDA_ENV_NAME }}-${{ hashFiles('conda-envs/${{ env.CONDA_ENV_NAME }}.yml') }}-${{ hashFiles('mlos_*/pyproject.toml') }}-${{ hashFiles('mlos_*/setup.py') }}-${{ env.cache_cur_date }}-${{ env.cache_prev_hour }} + conda-${{ runner.os }}-${{ env.CONDA_ENV_NAME }}-${{ hashFiles('conda-envs/${{ env.CONDA_ENV_NAME }}.yml') }}-${{ hashFiles('mlos_*/pyproject.toml') }}-${{ hashFiles('mlos_*/setup.py') }}-${{ env.cache_cur_date }} + + - name: Restore cached pip packages + id: restore-pip-cache + if: ${{ github.event_name != 'schedule' }} + uses: actions/cache@v4 + with: + path: ${{ env.PIP_CACHE_DIR }} + key: conda-${{ runner.os }}-${{ env.CONDA_ENV_NAME }}-${{ hashFiles('conda-envs/${{ env.CONDA_ENV_NAME }}.yml') }}-${{ hashFiles('mlos_*/pyproject.toml') }}-${{ hashFiles('mlos_*/setup.py') }}-${{ env.cache_cur_date }}-${{ env.cache_cur_hour }} + restore-keys: | + conda-${{ runner.os }}-${{ env.CONDA_ENV_NAME }}-${{ hashFiles('conda-envs/${{ env.CONDA_ENV_NAME }}.yml') }}-${{ hashFiles('mlos_*/pyproject.toml') }}-${{ hashFiles('mlos_*/setup.py') }}-${{ env.cache_cur_date }}-${{ env.cache_prev_hour }} + conda-${{ runner.os }}-${{ env.CONDA_ENV_NAME }}-${{ hashFiles('conda-envs/${{ env.CONDA_ENV_NAME }}.yml') }}-${{ hashFiles('mlos_*/pyproject.toml') }}-${{ hashFiles('mlos_*/setup.py') }}-${{ env.cache_cur_date }} + + - name: Log some environment variables for debugging + run: | + set -x + printenv + echo "cache_cur_date: $cache_cur_date" + echo "cache_cur_hour: $cache_cur_hour" + echo "cache_prev_hour: $cache_prev_hour" + echo "cache-hit: ${{ steps.restore-conda-cache.outputs.cache-hit }}" + + - name: Update and configure conda + run: | + set -x + conda config --set channel_priority strict + conda update -v -y -n base -c defaults --all + + # Try and speed up the pipeline by using a faster solver: + - name: Install and default to mamba solver + run: | + set -x + conda install -v -y -n base conda-libmamba-solver + # Try to set either of the configs for the solver. + conda config --set experimental_solver libmamba || true + conda config --set solver libmamba || true + echo "CONDA_EXPERIMENTAL_SOLVER=libmamba" >> $GITHUB_ENV + echo "EXPERIMENTAL_SOLVER=libmamba" >> $GITHUB_ENV + + - name: Create/update mlos conda environment + run: make CONDA_ENV_NAME=$CONDA_ENV_NAME CONDA_INFO_LEVEL=-v conda-env + + - name: Log conda info + run: | + conda info + conda config --show + conda config --show-sources + conda list -n $CONDA_ENV_NAME + ls -l $CONDA_DIR/envs/$CONDA_ENV_NAME/lib/python*/site-packages/ + conda run -n $CONDA_ENV_NAME pip cache dir + conda run -n $CONDA_ENV_NAME pip cache info + + - name: Verify expected version of python in conda env + if: ${{ matrix.python_version == '' }} + timeout-minutes: 2 + run: | + set -x + conda run -n mlos python -c \ + 'from sys import version_info as vers; assert (vers.major, vers.minor) == (3, 13), f"Unexpected python version: {vers}"' + + # This is moreso about code cleanliness, which is a dev thing, not a + # functionality thing, and the rules for that change between python versions, + # so only do this for the default in the devcontainer. + #- name: Run lint checks + # run: make CONDA_ENV_NAME=$CONDA_ENV_NAME check + + # Only run the coverage checks on the devcontainer job. + - name: Run tests + run: make CONDA_ENV_NAME=$CONDA_ENV_NAME SKIP_COVERAGE=true test + + - name: Generate and test binary distribution files + run: make CONDA_ENV_NAME=$CONDA_ENV_NAME CONDA_INFO_LEVEL=-v dist dist-test + + + MacOSDevContainerBuildTest: + name: MacOS DevContainer Build/Test + runs-on: macos-latest + + # Skip this for now. + # Note: no linux platform build support due to lack of nested virtualization on M series chips. + # https://github.com/orgs/community/discussions/69211#discussioncomment-7242133 + if: false + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Install docker + timeout-minutes: 15 + run: | + # Install the docker desktop app. + brew install --cask docker + brew install docker-buildx + brew install jq + # Make sure the cli knows where to find the buildx plugin. + mkdir -p ~/.docker + (cat ~/.docker/config.json 2>/dev/null || echo "{}") \ + | jq '.cliPluginsExtraDirs = ((.cliPluginsExtraDirs // []) + ["/opt/homebrew/lib/docker-cli-plugins"])' \ + | tee ~/.docker/config.json.new + mv ~/.docker/config.json.new ~/.docker/config.json + cat ~/.docker/config.json + # Restart docker service. + ps auxwww | grep -i docker || true + osascript -e 'quit app "Docker"' || true; open -a Docker; while [ -z "$(docker info 2> /dev/null )" ]; do printf "."; sleep 1; done; echo "" + + - name: Check docker + run: | + # Check and see if it's running. + ps auxwww | grep -i docker || true + ls -l /var/run/docker.sock + # Dump some debug info. + docker --version + docker info + docker system info || true + docker ps + DOCKER_BUILDKIT=1 docker builder ls + + - name: Build the devcontainer + run: | + .devcontainer/build/build-devcontainer.sh + + - name: Basic test of the devcontainer + run: | + .devcontainer/script/run-devcontainer.sh conda run -n mlos python --version | grep "Python 3.13" diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml index 5edfe706bb0..ebf9ed24454 100644 --- a/.github/workflows/markdown-link-check.yml +++ b/.github/workflows/markdown-link-check.yml @@ -19,6 +19,7 @@ concurrency: jobs: # Check in-repo markdown links markdown-link-check: + name: Check Markdown links runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 88f6bb15ddf..41adb19cfa9 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -20,7 +20,9 @@ concurrency: cancel-in-progress: true jobs: - Windows: + WindowsCondaBuildTest: + name: Windows Build/Test with Conda + runs-on: windows-latest permissions: @@ -123,3 +125,35 @@ jobs: - name: Generate and test binary distribution files run: | .github/workflows/build-dist-test.ps1 + + + WindowsDevContainerBuildTest: + name: Windows DevContainer Build/Test + # Skipped for now since building Linux containers on Windows Github Action Runners is not yet supported. + if: false + + runs-on: windows-latest + + defaults: + run: + shell: pwsh + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Check docker + run: | + docker info + docker builder ls | Select-String linux # current returns '' (not yet supported) + docker builder inspect + + - name: Build the devcontainer + run: | + .devcontainer/build/build-devcontainer.ps1 + + - name: Basic test of the devcontainer + run: | + .devcontainer/script/run-devcontainer.ps1 conda run -n mlos python --version diff --git a/Makefile b/Makefile index 672a1ce7c22..93bd4fd0be1 100644 --- a/Makefile +++ b/Makefile @@ -549,7 +549,7 @@ dist-test-env: dist build/dist-test-env.$(PYTHON_VERSION).build-stamp build/dist-test-env.$(PYTHON_VERSION).build-stamp: build/conda-env.${CONDA_ENV_NAME}.build-stamp # Use the same version of python as the one we used to build the wheels. -build/dist-test-env.$(PYTHON_VERSION).build-stamp: PYTHON_VERS_REQ=$(shell conda list -n ${CONDA_ENV_NAME} | egrep '^python\s+' | sed -r -e 's/^python\s+//' | cut -d' ' -f1 | cut -d. -f1-2) +build/dist-test-env.$(PYTHON_VERSION).build-stamp: PYTHON_VERS_REQ=$(shell conda list -n ${CONDA_ENV_NAME} | egrep '^python\s+' | sed -r -e 's/^python[ \t]+//' | cut -d' ' -f1 | cut -d. -f1-2) build/dist-test-env.$(PYTHON_VERSION).build-stamp: mlos_core/dist/tmp/mlos_core-latest-py3-none-any.whl build/dist-test-env.$(PYTHON_VERSION).build-stamp: mlos_bench/dist/tmp/mlos_bench-latest-py3-none-any.whl build/dist-test-env.$(PYTHON_VERSION).build-stamp: mlos_viz/dist/tmp/mlos_viz-latest-py3-none-any.whl diff --git a/README.md b/README.md index 31cc6da0213..2c1160dc938 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![MLOS DevContainer](https://github.com/microsoft/MLOS/actions/workflows/devcontainer.yml/badge.svg)](https://github.com/microsoft/MLOS/actions/workflows/devcontainer.yml) [![MLOS Linux](https://github.com/microsoft/MLOS/actions/workflows/linux.yml/badge.svg)](https://github.com/microsoft/MLOS/actions/workflows/linux.yml) +[![MLOS MacOS](https://github.com/microsoft/MLOS/actions/workflows/macos.yml/badge.svg)](https://github.com/microsoft/MLOS/actions/workflows/macos.yml) [![MLOS Windows](https://github.com/microsoft/MLOS/actions/workflows/windows.yml/badge.svg)](https://github.com/microsoft/MLOS/actions/workflows/windows.yml) [![Code Coverage Status](https://microsoft.github.io/MLOS/_images/coverage.svg)](https://microsoft.github.io/MLOS/htmlcov/index.html) diff --git a/mlos_bench/mlos_bench/environments/local/local_env.py b/mlos_bench/mlos_bench/environments/local/local_env.py index 989ae960398..754cdd34065 100644 --- a/mlos_bench/mlos_bench/environments/local/local_env.py +++ b/mlos_bench/mlos_bench/environments/local/local_env.py @@ -209,7 +209,7 @@ def run(self) -> Tuple[Status, datetime, Optional[Dict[str, TunableValue]]]: ) data = pandas.DataFrame([data.value.to_list()], columns=data.metric.to_list()) # Try to convert string metrics to numbers. - data = data.apply( # type: ignore[assignment] # (false positive) + data = data.apply( pandas.to_numeric, errors="coerce", ).fillna(data) diff --git a/mlos_bench/mlos_bench/services/remote/azure/azure_fileshare.py b/mlos_bench/mlos_bench/services/remote/azure/azure_fileshare.py index 6fa447da225..29a3829a136 100644 --- a/mlos_bench/mlos_bench/services/remote/azure/azure_fileshare.py +++ b/mlos_bench/mlos_bench/services/remote/azure/azure_fileshare.py @@ -110,7 +110,7 @@ def download( data = file_client.download_file() with open(local_path, "wb") as output_file: _LOG.debug("Download file: %s -> %s", remote_path, local_path) - data.readinto(output_file) # type: ignore[no-untyped-call] + data.readinto(output_file) except ResourceNotFoundError as ex: # Translate into non-Azure exception: raise FileNotFoundError(f"Cannot download: {remote_path}") from ex diff --git a/mlos_bench/mlos_bench/storage/sql/common.py b/mlos_bench/mlos_bench/storage/sql/common.py index 3b0c6c31fb0..918ed54ff2a 100644 --- a/mlos_bench/mlos_bench/storage/sql/common.py +++ b/mlos_bench/mlos_bench/storage/sql/common.py @@ -191,7 +191,7 @@ def get_results_df( columns="param", values="value", ) - configs_df = configs_df.apply( # type: ignore[assignment] # (fp) + configs_df = configs_df.apply( pandas.to_numeric, errors="coerce", ).fillna(configs_df) @@ -237,7 +237,7 @@ def get_results_df( columns="metric", values="value", ) - results_df = results_df.apply( # type: ignore[assignment] # (fp) + results_df = results_df.apply( pandas.to_numeric, errors="coerce", ).fillna(results_df) diff --git a/mlos_bench/mlos_bench/tests/conftest.py b/mlos_bench/mlos_bench/tests/conftest.py index bc0a8aa1897..2aa2138dabf 100644 --- a/mlos_bench/mlos_bench/tests/conftest.py +++ b/mlos_bench/mlos_bench/tests/conftest.py @@ -5,7 +5,8 @@ """Common fixtures for mock TunableGroups and Environment objects.""" import os -from typing import Any, Generator, List +import sys +from typing import Any, Generator, List, Union import pytest from fasteners import InterProcessLock, InterProcessReaderWriterLock @@ -58,6 +59,17 @@ def mock_env_no_noise(tunable_groups: TunableGroups) -> MockEnv: # Fixtures to configure the pytest-docker plugin. +@pytest.fixture(scope="session") +def docker_setup() -> Union[List[str], str]: + """Setup for docker services.""" + if sys.platform == "darwin" or os.environ.get("HOST_OSTYPE", "").lower().startswith("darwin"): + # Workaround an oddity on macOS where the "docker-compose up" + # command always recreates the containers. + # That leads to races when multiple workers are trying to + # start and use the same services. + return ["up --build -d --no-recreate"] + else: + return ["up --build -d"] @pytest.fixture(scope="session") diff --git a/mlos_bench/mlos_bench/tests/optimizers/mlos_core_opt_smac_test.py b/mlos_bench/mlos_bench/tests/optimizers/mlos_core_opt_smac_test.py index 23aa56e48cb..5f96b552e5b 100644 --- a/mlos_bench/mlos_bench/tests/optimizers/mlos_core_opt_smac_test.py +++ b/mlos_bench/mlos_bench/tests/optimizers/mlos_core_opt_smac_test.py @@ -70,9 +70,11 @@ def test_init_mlos_core_smac_relative_output_directory(tunable_groups: TunableGr """Test relative path output directory initialization of mlos_core SMAC optimizer. """ + uid = os.environ.get("PYTEST_XDIST_WORKER", "") + output_dir = _OUTPUT_DIR + "." + uid test_opt_config = { "optimizer_type": "SMAC", - "output_directory": _OUTPUT_DIR, + "output_directory": output_dir, "seed": SEED, } opt = MlosCoreOptimizer(tunable_groups, test_opt_config) @@ -82,7 +84,7 @@ def test_init_mlos_core_smac_relative_output_directory(tunable_groups: TunableGr assert path_join(str(opt._opt.base_optimizer.scenario.output_directory)).startswith( path_join(os.getcwd(), str(test_opt_config["output_directory"])) ) - shutil.rmtree(_OUTPUT_DIR) + shutil.rmtree(output_dir) def test_init_mlos_core_smac_relative_output_directory_with_run_name( @@ -91,9 +93,11 @@ def test_init_mlos_core_smac_relative_output_directory_with_run_name( """Test relative path output directory initialization of mlos_core SMAC optimizer. """ + uid = os.environ.get("PYTEST_XDIST_WORKER", "") + output_dir = _OUTPUT_DIR + "." + uid test_opt_config = { "optimizer_type": "SMAC", - "output_directory": _OUTPUT_DIR, + "output_directory": output_dir, "run_name": "test_run", "seed": SEED, } @@ -106,7 +110,7 @@ def test_init_mlos_core_smac_relative_output_directory_with_run_name( os.getcwd(), str(test_opt_config["output_directory"]), str(test_opt_config["run_name"]) ) ) - shutil.rmtree(_OUTPUT_DIR) + shutil.rmtree(output_dir) def test_init_mlos_core_smac_relative_output_directory_with_experiment_id( diff --git a/mlos_bench/mlos_bench/tests/services/remote/ssh/test_ssh_service.py b/mlos_bench/mlos_bench/tests/services/remote/ssh/test_ssh_service.py index 5b335477a98..d06516b3fe3 100644 --- a/mlos_bench/mlos_bench/tests/services/remote/ssh/test_ssh_service.py +++ b/mlos_bench/mlos_bench/tests/services/remote/ssh/test_ssh_service.py @@ -132,4 +132,4 @@ def test_ssh_service_context_handler() -> None: if __name__ == "__main__": # For debugging in Windows which has issues with pytest detection in vscode. - pytest.main(["-n1", "--dist=no", "-k", "test_ssh_service_background_thread"]) + pytest.main(["-n0", "--dist=no", "-k", "test_ssh_service_context_handler"]) diff --git a/mlos_bench/mlos_bench/tests/services/remote/ssh/up.sh b/mlos_bench/mlos_bench/tests/services/remote/ssh/up.sh index 42bc984e6e5..f0f152975dc 100755 --- a/mlos_bench/mlos_bench/tests/services/remote/ssh/up.sh +++ b/mlos_bench/mlos_bench/tests/services/remote/ssh/up.sh @@ -28,3 +28,5 @@ echo "OK: private key available at '$scriptdir/id_rsa'. Connect to the ssh-serve docker compose -p "$PROJECT_NAME" port ssh-server ${PORT:-2254} | cut -d: -f2 echo "INFO: And this port for the alt-server container:" docker compose -p "$PROJECT_NAME" port alt-server ${PORT:-2254} | cut -d: -f2 +echo "INFO: And this port for the reboot-server container:" +docker compose -p "$PROJECT_NAME" port reboot-server ${PORT:-2254} | cut -d: -f2 From 6b9bbd97d58e8cd05eb6a57e106e0e6ef34dc1db Mon Sep 17 00:00:00 2001 From: Eu Jing Chua Date: Wed, 13 Nov 2024 15:18:49 -0800 Subject: [PATCH 30/36] CyclicConfigSuggestor (#855) Currently used for validating a default configuration against some other specific configuration, in an alternating manner on the same MySQL instance. This sets up the collected pairs of metrics for something like a matched-pair t-test, without the noise of making comparisons across different MySQL instances. More generally, multiple configs can be specified via the list `cycle_tunable_values` to cycle through in optimization mode. EDIT: Originally implemented as a scheduler, now is a "ManualOptimizer" instead. --------- Co-authored-by: Eu Jing Chua Co-authored-by: Brian Kroth Co-authored-by: Brian Kroth Co-authored-by: Sergiy Matusevych Co-authored-by: Sergiy Matusevych --- .../config/optimizers/manual_opt.jsonc | 16 ++++++ .../schemas/optimizers/optimizer-schema.json | 48 ++++++++++++++++++ mlos_bench/mlos_bench/optimizers/__init__.py | 2 + .../mlos_bench/optimizers/manual_optimizer.py | 49 +++++++++++++++++++ .../optimizers/one_shot_optimizer.py | 2 - .../manual_opt_schema_bad_max_cycles.jsonc | 10 ++++ .../manual_opt_schema_empty_config.jsonc | 4 ++ .../bad/unhandled/manual_opt_extra.jsonc | 8 +++ .../good/full/manual_opt_full.jsonc | 24 +++++++++ .../good/partial/manual_opt_no_schema.jsonc | 3 ++ .../manual_opt_schema_partial_config.jsonc | 7 +++ .../mlos_bench/tests/optimizers/conftest.py | 16 ++++++ .../tests/optimizers/manual_opt_test.py | 21 ++++++++ 13 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 mlos_bench/mlos_bench/config/optimizers/manual_opt.jsonc create mode 100644 mlos_bench/mlos_bench/optimizers/manual_optimizer.py create mode 100644 mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/bad/invalid/manual_opt_schema_bad_max_cycles.jsonc create mode 100644 mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/bad/invalid/manual_opt_schema_empty_config.jsonc create mode 100644 mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/bad/unhandled/manual_opt_extra.jsonc create mode 100644 mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/full/manual_opt_full.jsonc create mode 100644 mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/partial/manual_opt_no_schema.jsonc create mode 100644 mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/partial/manual_opt_schema_partial_config.jsonc create mode 100644 mlos_bench/mlos_bench/tests/optimizers/manual_opt_test.py diff --git a/mlos_bench/mlos_bench/config/optimizers/manual_opt.jsonc b/mlos_bench/mlos_bench/config/optimizers/manual_opt.jsonc new file mode 100644 index 00000000000..2b1277eba35 --- /dev/null +++ b/mlos_bench/mlos_bench/config/optimizers/manual_opt.jsonc @@ -0,0 +1,16 @@ +// Manual optimizer to run fixed set of tunable values on repeat via the benchmarking framework. +{ + "$schema": "https://raw.githubusercontent.com/microsoft/MLOS/main/mlos_bench/mlos_bench/config/schemas/optimizers/optimizer-schema.json", + + "class": "mlos_bench.optimizers.ManualOptimizer", + + "config": { + "max_cycles": 30, + "tunable_values_cycle": [ + // Add one or more set of tunable values here as a dictionary: + // {"tunable_param_1": "tunable_value_1", ... }, + // The empty dictionary {} can be used to represent the default tunable values. + {} + ] + } +} diff --git a/mlos_bench/mlos_bench/config/schemas/optimizers/optimizer-schema.json b/mlos_bench/mlos_bench/config/schemas/optimizers/optimizer-schema.json index 92bcee5574b..b320c481090 100644 --- a/mlos_bench/mlos_bench/config/schemas/optimizers/optimizer-schema.json +++ b/mlos_bench/mlos_bench/config/schemas/optimizers/optimizer-schema.json @@ -55,6 +55,8 @@ "description": "The name of the optimizer class to use.", "$comment": "required", "enum": [ + "mlos_bench.optimizers.ManualOptimizer", + "mlos_bench.optimizers.manual_optimizer.ManualOptimizer", "mlos_bench.optimizers.MlosCoreOptimizer", "mlos_bench.optimizers.mlos_core_optimizer.MlosCoreOptimizer", "mlos_bench.optimizers.GridSearchOptimizer", @@ -201,6 +203,52 @@ } }, "else": false + }, + + { + "$comment": "extensions to the 'config' object properties when the manual optimizer is being used", + "if": { + "properties": { + "class": { + "enum": [ + "mlos_bench.optimizers.ManualOptimizer", + "mlos_bench.optimizers.manual_optimizer.ManualOptimizer" + ] + } + }, + "required": ["class"] + }, + "then": { + "properties": { + "config": { + "type": "object", + "allOf": [ + { "$ref": "#/$defs/config_base_optimizer" }, + { + "type": "object", + "properties": { + "max_cycles": { + "description": "The maximum number of cycles of tunable values to run the optimizer for.", + "type": "integer", + "minimum": 1 + }, + "tunable_values_cycle": { + "description": "The tunable values to cycle through.", + "type": "array", + "items": { + "$ref": "../tunables/tunable-values-schema.json#/$defs/tunable_values_set" + }, + "minItems": 1 + } + } + } + ], + "$comment": "disallow other properties", + "unevaluatedProperties": false + } + } + }, + "else": false } ], "unevaluatedProperties": false diff --git a/mlos_bench/mlos_bench/optimizers/__init__.py b/mlos_bench/mlos_bench/optimizers/__init__.py index 7cd6a8a25a4..106b0fc496b 100644 --- a/mlos_bench/mlos_bench/optimizers/__init__.py +++ b/mlos_bench/mlos_bench/optimizers/__init__.py @@ -5,12 +5,14 @@ """Interfaces and wrapper classes for optimizers to be used in Autotune.""" from mlos_bench.optimizers.base_optimizer import Optimizer +from mlos_bench.optimizers.manual_optimizer import ManualOptimizer from mlos_bench.optimizers.mlos_core_optimizer import MlosCoreOptimizer from mlos_bench.optimizers.mock_optimizer import MockOptimizer from mlos_bench.optimizers.one_shot_optimizer import OneShotOptimizer __all__ = [ "Optimizer", + "ManualOptimizer", "MockOptimizer", "OneShotOptimizer", "MlosCoreOptimizer", diff --git a/mlos_bench/mlos_bench/optimizers/manual_optimizer.py b/mlos_bench/mlos_bench/optimizers/manual_optimizer.py new file mode 100644 index 00000000000..d9f48a4e193 --- /dev/null +++ b/mlos_bench/mlos_bench/optimizers/manual_optimizer.py @@ -0,0 +1,49 @@ +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +"""Optimizer for mlos_bench that proposes an explicit sequence of configurations.""" + +import logging +from typing import Dict, List, Optional + +from mlos_bench.optimizers.mock_optimizer import MockOptimizer +from mlos_bench.services.base_service import Service +from mlos_bench.tunables.tunable import TunableValue +from mlos_bench.tunables.tunable_groups import TunableGroups + +_LOG = logging.getLogger(__name__) + + +class ManualOptimizer(MockOptimizer): + """Optimizer that proposes an explicit sequence of tunable values.""" + + def __init__( + self, + tunables: TunableGroups, + config: dict, + global_config: Optional[dict] = None, + service: Optional[Service] = None, + ): + super().__init__(tunables, config, global_config, service) + self._tunable_values_cycle: List[Dict[str, TunableValue]] = config.get( + "tunable_values_cycle", [] + ) + assert len(self._tunable_values_cycle) > 0, "No tunable values provided." + max_cycles = int(config.get("max_cycles", 1)) + self._max_suggestions = min( + self._max_suggestions, + max_cycles * len(self._tunable_values_cycle), + ) + + def suggest(self) -> TunableGroups: + """Always produce the same sequence of explicit suggestions, in a cycle.""" + tunables = super().suggest() + cycle_index = (self._iter - 1) % len(self._tunable_values_cycle) + tunables.assign(self._tunable_values_cycle[cycle_index]) + _LOG.info("Iteration %d :: Suggest: %s", self._iter, tunables) + return tunables + + @property + def supports_preload(self) -> bool: + return False diff --git a/mlos_bench/mlos_bench/optimizers/one_shot_optimizer.py b/mlos_bench/mlos_bench/optimizers/one_shot_optimizer.py index cae735a8496..f5fc7c8baa7 100644 --- a/mlos_bench/mlos_bench/optimizers/one_shot_optimizer.py +++ b/mlos_bench/mlos_bench/optimizers/one_shot_optimizer.py @@ -21,8 +21,6 @@ class OneShotOptimizer(MockOptimizer): Explicit configs (partial or full) are possible using configuration files. """ - # TODO: Add support for multiple explicit configs (i.e., FewShot or Manual Optimizer) - #344 - def __init__( self, tunables: TunableGroups, diff --git a/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/bad/invalid/manual_opt_schema_bad_max_cycles.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/bad/invalid/manual_opt_schema_bad_max_cycles.jsonc new file mode 100644 index 00000000000..3803e71aed9 --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/bad/invalid/manual_opt_schema_bad_max_cycles.jsonc @@ -0,0 +1,10 @@ +{ + + "class": "mlos_bench.optimizers.ManualOptimizer", + + "config": { + // max_cycles should be at least 1 + "max_cycles": 0, + "tunable_values_cycle": [] + } +} diff --git a/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/bad/invalid/manual_opt_schema_empty_config.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/bad/invalid/manual_opt_schema_empty_config.jsonc new file mode 100644 index 00000000000..eac91f69c1a --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/bad/invalid/manual_opt_schema_empty_config.jsonc @@ -0,0 +1,4 @@ +{ + "class": "mlos_bench.optimizers.ManualOptimizer", + "config": {} +} diff --git a/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/bad/unhandled/manual_opt_extra.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/bad/unhandled/manual_opt_extra.jsonc new file mode 100644 index 00000000000..4464b5ff0a9 --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/bad/unhandled/manual_opt_extra.jsonc @@ -0,0 +1,8 @@ +{ + "class": "mlos_bench.optimizers.ManualOptimizer", + + "config": { + "tunable_values_cycle": [], + "extra_param": "should not be here" + } +} diff --git a/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/full/manual_opt_full.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/full/manual_opt_full.jsonc new file mode 100644 index 00000000000..d4f0553fb25 --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/full/manual_opt_full.jsonc @@ -0,0 +1,24 @@ +{ + "class": "mlos_bench.optimizers.ManualOptimizer", + + "config": { + // Here we do our best to list the exhaustive set of full configs available for the base optimizer config. + "optimization_targets": {"score": "min"}, + "max_suggestions": 20, + "seed": 12345, + "start_with_defaults": false, + "max_cycles": 10, + "tunable_values_cycle": [ + { + "param1": "value1", + "param2": 1, + "param3": false + }, + { + "param1": "value2", + "param2": 2, + "param3": true + }, + ] + } +} diff --git a/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/partial/manual_opt_no_schema.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/partial/manual_opt_no_schema.jsonc new file mode 100644 index 00000000000..90a05fc8a8a --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/partial/manual_opt_no_schema.jsonc @@ -0,0 +1,3 @@ +{ + "class": "mlos_bench.optimizers.ManualOptimizer" +} diff --git a/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/partial/manual_opt_schema_partial_config.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/partial/manual_opt_schema_partial_config.jsonc new file mode 100644 index 00000000000..5df29d6bb18 --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/partial/manual_opt_schema_partial_config.jsonc @@ -0,0 +1,7 @@ +{ + + "class": "mlos_bench.optimizers.ManualOptimizer", + "config": { + "tunable_values_cycle": [{}] + } +} diff --git a/mlos_bench/mlos_bench/tests/optimizers/conftest.py b/mlos_bench/mlos_bench/tests/optimizers/conftest.py index d4418f58b31..edce9e6db88 100644 --- a/mlos_bench/mlos_bench/tests/optimizers/conftest.py +++ b/mlos_bench/mlos_bench/tests/optimizers/conftest.py @@ -8,11 +8,14 @@ import pytest +from mlos_bench.optimizers.manual_optimizer import ManualOptimizer from mlos_bench.optimizers.mlos_core_optimizer import MlosCoreOptimizer from mlos_bench.optimizers.mock_optimizer import MockOptimizer from mlos_bench.tests import SEED from mlos_bench.tunables.tunable_groups import TunableGroups +# pylint: disable=redefined-outer-name + @pytest.fixture def mock_configs() -> List[dict]: @@ -154,3 +157,16 @@ def smac_opt_max(tunable_groups: TunableGroups) -> MlosCoreOptimizer: "max_ratio": 1.0, }, ) + + +@pytest.fixture +def manual_opt(tunable_groups: TunableGroups, mock_configs: List[dict]) -> ManualOptimizer: + """Test fixture for ManualOptimizer.""" + return ManualOptimizer( + tunables=tunable_groups, + service=None, + config={ + "max_cycles": 2, + "tunable_values_cycle": mock_configs, + }, + ) diff --git a/mlos_bench/mlos_bench/tests/optimizers/manual_opt_test.py b/mlos_bench/mlos_bench/tests/optimizers/manual_opt_test.py new file mode 100644 index 00000000000..313cb5a53d2 --- /dev/null +++ b/mlos_bench/mlos_bench/tests/optimizers/manual_opt_test.py @@ -0,0 +1,21 @@ +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +"""Unit tests for mock mlos_bench optimizer.""" + +from mlos_bench.environments.status import Status +from mlos_bench.optimizers.manual_optimizer import ManualOptimizer + +# pylint: disable=redefined-outer-name + + +def test_manual_optimizer(manual_opt: ManualOptimizer, mock_configs: list) -> None: + """Make sure that manual optimizer produces consistent suggestions.""" + + i = 0 + while manual_opt.not_converged(): + tunables = manual_opt.suggest() + assert tunables.get_param_values() == mock_configs[i % len(mock_configs)] + manual_opt.register(tunables, Status.SUCCEEDED, {"score": 123.0}) + i += 1 From d408ab9f7b5e8b78a51f71f33c6311578f0f85e6 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Fri, 15 Nov 2024 12:59:31 -0600 Subject: [PATCH 31/36] Rework documentation generation (#869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title Documentation generation rework. --- ## Description This PR does an initial revamp of the way we generate and write documentation. The goal is to simplify the generation process a bit by removing some hacks but also enable more strict checking of the documentation produced so that links resolve properly so that it is easier to navigate. This PR is mostly the mechanics and the associated fixups to do that. Future PRs can handle additional documentation in docstrings. Below is a more detailed description of some of the changes. - [x] Apply `black` and `pylint` to the sphinx `conf.py` - [x] Treat sphinx warnings as errors - [x] Reorg doc generation using `autoapi` to handle cross referencing warnings and drop use of `sphinx-apidoc` - [x] ~Update `overview.rst` to follow suite~ - [x] Alternatively, move overview text into the docstrings and "just" use `autoapi` for everything. - [x] Use `:py:func:`, `:py:class:`, `:py:meth:`, `:py:mod:`, etc. to cross reference functions and classes in the docstrings. - [x] Add `napolean` to parse `numpydoc` style docstrings (previous `numpydoc` method was incomplete) - [x] Use `intersphinx` and `nitpick` to check (or ignore where not possible) all internal and most external cross references to ensure good code navigation - [x] Fixup all broken references by either adding fully qualified types in docstrings or in the imports. - [x] Ignore type aliase for for now (documented bug, will address in a future PR) - [ ] Add a few high level examples in docstrings. - [ ] May need `from_config_string` style loaders to help with this. - [x] Add `doctest` to `pytest` to validate examples. - [x] Update `mlos_bench.run` help usage output to an `rst` file and link that into the man table of contents tree in the output. --- ## Type of Change - 🛠️ Bug fix - 📝 Documentation update - 🧪 Tests --- ## Testing - Adds additional checks and linting. - Must pass CI - Manual testing as follows: ```sh # SKIP_COVERAGE tweak is optional - just avoids a `pytest` job dependency make SKIP_COVERAGE=true doc ./doc/nginx-docker.sh restart ``` Browse to `http://localhost:8080` and check the results. --- ## Additional Notes Future PRs can add additional documentation strings for the `mlos_bench` classes including examples. In particular - [ ] mlos_bench.optimizers - [ ] mlos_bench.services --------- Co-authored-by: Sergiy Matusevych --- .github/workflows/devcontainer.yml | 1 - .vscode/settings.json | 14 + CONTRIBUTING.md | 2 + MAINTAINING.md | 4 + Makefile | 83 ++--- README.md | 5 +- doc/README.md | 89 +++++- doc/copy-source-tree-docs.sh | 1 + doc/requirements.txt | 3 +- doc/source/.gitignore | 1 + doc/source/_templates/class.rst | 16 - doc/source/_templates/function.rst | 12 - doc/source/_templates/numpydoc_docstring.py | 20 -- doc/source/conf.py | 218 +++++++++++-- doc/source/index.rst | 31 +- doc/source/mlos_bench.run.usage.rst | 10 + doc/source/overview.rst | 298 ------------------ mlos_bench/mlos_bench/__init__.py | 128 +++++++- mlos_bench/mlos_bench/config/__init__.py | 260 ++++++++++++++- .../boot/scripts/local/create_new_grub_cfg.py | 20 +- .../mlos_bench/config/schemas/__init__.py | 9 +- .../config/schemas/config_schemas.py | 111 ++++++- mlos_bench/mlos_bench/dict_templater.py | 11 +- .../mlos_bench/environments/__init__.py | 113 ++++++- .../environments/base_environment.py | 6 +- .../mlos_bench/environments/composite_env.py | 8 +- .../environments/local/local_env.py | 22 +- .../environments/local/local_fileshare_env.py | 2 +- .../mlos_bench/environments/mock_env.py | 7 +- .../environments/remote/remote_env.py | 6 +- .../mlos_bench/environments/script_env.py | 28 +- mlos_bench/mlos_bench/event_loop_context.py | 13 +- mlos_bench/mlos_bench/launcher.py | 4 +- mlos_bench/mlos_bench/optimizers/__init__.py | 7 +- .../mlos_bench/optimizers/base_optimizer.py | 7 +- .../optimizers/convert_configspace.py | 6 +- .../mlos_bench/optimizers/manual_optimizer.py | 10 +- .../optimizers/mlos_core_optimizer.py | 3 +- mlos_bench/mlos_bench/os_environ.py | 13 +- mlos_bench/mlos_bench/run.py | 11 +- .../mlos_bench/schedulers/base_scheduler.py | 3 +- mlos_bench/mlos_bench/services/__init__.py | 6 +- .../mlos_bench/services/base_service.py | 4 +- .../mlos_bench/services/config_persistence.py | 2 +- .../services/local/temp_dir_context.py | 2 +- .../remote/azure/azure_network_services.py | 6 +- .../services/remote/azure/azure_saas.py | 8 +- .../remote/azure/azure_vm_services.py | 12 +- .../services/remote/ssh/ssh_fileshare.py | 4 +- .../services/remote/ssh/ssh_host_service.py | 6 +- .../services/remote/ssh/ssh_service.py | 6 +- .../services/types/authenticator_type.py | 2 +- .../services/types/config_loader_type.py | 2 +- .../services/types/host_ops_type.py | 6 +- .../services/types/host_provisioner_type.py | 6 +- .../services/types/local_exec_type.py | 2 +- .../types/network_provisioner_type.py | 6 +- .../mlos_bench/services/types/os_ops_type.py | 4 +- .../services/types/remote_config_type.py | 2 +- .../services/types/vm_provisioner_type.py | 10 +- mlos_bench/mlos_bench/storage/__init__.py | 53 +++- .../storage/base_experiment_data.py | 26 +- mlos_bench/mlos_bench/storage/base_storage.py | 38 ++- .../mlos_bench/storage/base_trial_data.py | 7 +- .../storage/base_tunable_config_data.py | 7 +- .../base_tunable_config_trial_group_data.py | 9 +- mlos_bench/mlos_bench/storage/sql/__init__.py | 25 +- mlos_bench/mlos_bench/storage/sql/common.py | 3 +- .../mlos_bench/storage/sql/experiment.py | 7 +- .../mlos_bench/storage/sql/experiment_data.py | 7 +- mlos_bench/mlos_bench/storage/sql/schema.py | 14 +- mlos_bench/mlos_bench/storage/sql/storage.py | 4 +- mlos_bench/mlos_bench/storage/sql/trial.py | 7 +- .../mlos_bench/storage/sql/trial_data.py | 6 +- .../storage/sql/tunable_config_data.py | 6 +- .../sql/tunable_config_trial_group_data.py | 7 +- .../mlos_bench/storage/storage_factory.py | 10 +- .../partial/grid_search_opt_minimal.jsonc | 4 + .../tests/event_loop_context_test.py | 3 +- mlos_bench/mlos_bench/tunables/tunable.py | 4 +- .../mlos_bench/tunables/tunable_groups.py | 2 +- mlos_bench/mlos_bench/util.py | 28 +- mlos_core/mlos_core/__init__.py | 107 ++++++- mlos_core/mlos_core/optimizers/__init__.py | 54 +++- .../bayesian_optimizers/bayesian_optimizer.py | 8 +- .../bayesian_optimizers/smac_optimizer.py | 33 +- .../mlos_core/optimizers/flaml_optimizer.py | 9 +- mlos_core/mlos_core/optimizers/optimizer.py | 51 +-- .../mlos_core/optimizers/random_optimizer.py | 14 +- mlos_core/mlos_core/spaces/__init__.py | 2 +- .../mlos_core/spaces/adapters/__init__.py | 45 ++- .../mlos_core/spaces/adapters/adapter.py | 31 +- .../mlos_core/spaces/adapters/llamatune.py | 18 +- .../mlos_core/spaces/converters/__init__.py | 9 +- .../mlos_core/spaces/converters/flaml.py | 5 +- mlos_core/mlos_core/spaces/converters/util.py | 13 +- .../mlos_core/tests/spaces/spaces_test.py | 2 +- mlos_core/mlos_core/util.py | 6 +- mlos_viz/README.md | 2 +- mlos_viz/mlos_viz/__init__.py | 18 +- mlos_viz/mlos_viz/base.py | 29 +- mlos_viz/mlos_viz/dabl.py | 14 +- mlos_viz/mlos_viz/util.py | 10 +- pyproject.toml | 15 + setup.cfg | 5 +- 105 files changed, 1768 insertions(+), 721 deletions(-) delete mode 100644 doc/source/_templates/class.rst delete mode 100644 doc/source/_templates/function.rst delete mode 100644 doc/source/_templates/numpydoc_docstring.py create mode 100644 doc/source/mlos_bench.run.usage.rst delete mode 100644 doc/source/overview.rst create mode 100644 mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/partial/grid_search_opt_minimal.jsonc diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index c0d41a3d72c..ae009107508 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -198,7 +198,6 @@ jobs: rm -f doc/build/html/htmlcov/.gitignore - uses: actions/upload-artifact@v4 - if: github.ref == 'refs/heads/main' with: name: docs path: doc/build/html diff --git a/.vscode/settings.json b/.vscode/settings.json index 982eb42c8dd..759b34a412a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,18 @@ // vim: set ft=jsonc: { "makefile.extensionOutputFolder": "./.vscode", + "files.exclude": { + ".git/": true, + ".mypy_cache/": true, + ".pytest_cache/": true, + "**/__pycache__/": true, + "**/node_modules/": true, + "**/*.egg-info": true, + "doc/source/autoapi/": true, + "doc/build/doctrees/": true, + "doc/build/html/": true, + "htmlcov/": true, + }, // Note: this only works in WSL/Linux currently. "python.defaultInterpreterPath": "${env:HOME}/.conda/envs/mlos/bin/python", // For Windows it should be this instead: @@ -26,6 +38,8 @@ "mlos_bench/mlos_bench/tests/config/environments/**/*.json", "mlos_bench/mlos_bench/config/environments/**/*.jsonc", "mlos_bench/mlos_bench/config/environments/**/*.json", + "!mlos_bench/mlos_bench/tests/config/environments/**/*-tunables.jsonc", + "!mlos_bench/mlos_bench/tests/config/environments/**/*-tunables.json", "!mlos_bench/mlos_bench/config/environments/**/*-tunables.jsonc", "!mlos_bench/mlos_bench/config/environments/**/*-tunables.json" ], diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c32eaa836ec..163f48cb6c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,6 +86,8 @@ We expect development to follow a typical "forking" style workflow: make doc-test ``` + > See the [documentation README](./doc/README.md) for more information on documentation and its testing. + 1. Submit changes for inclusion as a [Pull Request on Github](https://github.com/microsoft/MLOS/pulls). Some notes on organizing changes to help reviewers: diff --git a/MAINTAINING.md b/MAINTAINING.md index 54f60c016ab..9a7f6060365 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -2,6 +2,10 @@ Some notes for maintainers. +## Documentation + +See the [documentation README](./doc/README.md) for more information on writing (and testing) documentation. + ## Releasing 1. Bump the version using the [`update-version.sh`](./scripts/update-version.sh) script: diff --git a/Makefile b/Makefile index 93bd4fd0be1..d465b2b70ea 100644 --- a/Makefile +++ b/Makefile @@ -659,49 +659,32 @@ clean-doc-env: COMMON_DOC_FILES := build/doc-prereqs.${CONDA_ENV_NAME}.build-stamp doc/source/*.rst doc/source/_templates/*.rst doc/source/conf.py -doc/source/api/mlos_core/modules.rst: $(FORMAT_PREREQS) $(COMMON_DOC_FILES) -doc/source/api/mlos_core/modules.rst: $(MLOS_CORE_PYTHON_FILES) - rm -rf doc/source/api/mlos_core - cd doc/ && conda run -n ${CONDA_ENV_NAME} sphinx-apidoc -f -e -M \ - -o source/api/mlos_core/ \ - ../mlos_core/ \ - ../mlos_core/setup.py ../mlos_core/mlos_core/tests/ - -doc/source/api/mlos_bench/modules.rst: $(FORMAT_PREREQS) $(COMMON_DOC_FILES) -doc/source/api/mlos_bench/modules.rst: $(MLOS_BENCH_PYTHON_FILES) - rm -rf doc/source/api/mlos_bench - cd doc/ && conda run -n ${CONDA_ENV_NAME} sphinx-apidoc -f -e -M \ - -o source/api/mlos_bench/ \ - ../mlos_bench/ \ - ../mlos_bench/setup.py ../mlos_bench/mlos_bench/tests/ - # Save the help output of the mlos_bench scripts to include in the documentation. - # First make sure that the latest version of mlos_bench is installed (since it uses git based tagging). - conda run -n ${CONDA_ENV_NAME} pip install -e mlos_core -e mlos_bench -e mlos_viz - conda run -n ${CONDA_ENV_NAME} mlos_bench --help > doc/source/api/mlos_bench/mlos_bench.run.usage.txt - echo ".. literalinclude:: mlos_bench.run.usage.txt" >> doc/source/api/mlos_bench/mlos_bench.run.rst - echo " :language: none" >> doc/source/api/mlos_bench/mlos_bench.run.rst - -doc/source/api/mlos_viz/modules.rst: $(FORMAT_PREREQS) $(COMMON_DOC_FILES) -doc/source/api/mlos_viz/modules.rst: $(MLOS_VIZ_PYTHON_FILES) - rm -rf doc/source/api/mlos_viz - cd doc/ && conda run -n ${CONDA_ENV_NAME} sphinx-apidoc -f -e -M \ - -o source/api/mlos_viz/ \ - ../mlos_viz/ \ - ../mlos_viz/setup.py ../mlos_viz/mlos_viz/tests/ - -SPHINX_API_RST_FILES := doc/source/api/mlos_core/modules.rst -SPHINX_API_RST_FILES += doc/source/api/mlos_bench/modules.rst -SPHINX_API_RST_FILES += doc/source/api/mlos_viz/modules.rst - -.PHONY: sphinx-apidoc -sphinx-apidoc: $(SPHINX_API_RST_FILES) +SPHINX_API_RST_FILES := doc/source/index.rst doc/source/mlos_bench.run.usage.rst ifeq ($(SKIP_COVERAGE),) doc/build/html/index.html: build/pytest.${CONDA_ENV_NAME}.build-stamp doc/build/html/htmlcov/index.html: build/pytest.${CONDA_ENV_NAME}.build-stamp endif -doc/build/html/index.html: $(SPHINX_API_RST_FILES) doc/Makefile doc/copy-source-tree-docs.sh $(MD_FILES) +# Treat warnings as failures. +SPHINXOPTS ?= # -v # be verbose +SPHINXOPTS += -n -W -w $(CURDIR)/doc/build/sphinx-build.warn.log -j auto + +sphinx-apidoc: doc/build/html/index.html + +doc/source/generated/mlos_bench.run.usage.txt: build/conda-env.${CONDA_ENV_NAME}.build-stamp +doc/source/generated/mlos_bench.run.usage.txt: $(MLOS_BENCH_PYTHON_FILES) + # Generate the help output from mlos_bench CLI for the docs. + mkdir -p doc/source/generated/ + conda run -n ${CONDA_ENV_NAME} mlos_bench --help > doc/source/generated/mlos_bench.run.usage.txt + +doc/build/html/index.html: build/doc-prereqs.${CONDA_ENV_NAME}.build-stamp +doc/build/html/index.html: doc/source/generated/mlos_bench.run.usage.txt +doc/build/html/index.html: $(MLOS_CORE_PYTHON_FILES) +doc/build/html/index.html: $(MLOS_BENCH_PYTHON_FILES) +doc/build/html/index.html: $(MLOS_VIZ_PYTHON_FILES) +doc/build/html/index.html: $(SPHINX_API_RST_FILES) doc/Makefile doc/source/conf.py +doc/build/html/index.html: doc/copy-source-tree-docs.sh $(MD_FILES) @rm -rf doc/build @mkdir -p doc/build @rm -f doc/build/log.txt @@ -715,7 +698,7 @@ doc/build/html/index.html: $(SPHINX_API_RST_FILES) doc/Makefile doc/copy-source- ./doc/copy-source-tree-docs.sh # Build the rst files into html. - conda run -n ${CONDA_ENV_NAME} $(MAKE) -C doc/ $(MAKEFLAGS) html \ + conda run -n ${CONDA_ENV_NAME} $(MAKE) SPHINXOPTS="$(SPHINXOPTS)" -C doc/ $(MAKEFLAGS) html \ >> doc/build/log.txt 2>&1 \ || { cat doc/build/log.txt; exit 1; } # DONE: Add some output filtering for this so we can more easily see what went wrong. @@ -744,27 +727,21 @@ check-doc: build/check-doc.build-stamp build/check-doc.build-stamp: doc/build/html/index.html doc/build/html/htmlcov/index.html # Check for a few files to make sure the docs got generated in a way we want. test -s doc/build/html/index.html - test -s doc/build/html/generated/mlos_core.optimizers.optimizer.BaseOptimizer.html - test -s doc/build/html/generated/mlos_bench.environments.Environment.html - test -s doc/build/html/generated/mlos_viz.plot.html - test -s doc/build/html/api/mlos_core/mlos_core.html - test -s doc/build/html/api/mlos_bench/mlos_bench.html - test -s doc/build/html/api/mlos_viz/mlos_viz.html - test -s doc/build/html/api/mlos_viz/mlos_viz.dabl.html - grep -q -e '--config CONFIG' doc/build/html/api/mlos_bench/mlos_bench.run.html + grep -q BaseOptimizer doc/build/html/autoapi/mlos_core/optimizers/optimizer/index.html + grep -q Environment doc/build/html/autoapi/mlos_bench/environments/base_environment/index.html + grep -q plot doc/build/html/autoapi/mlos_viz/index.html + test -s doc/build/html/autoapi/mlos_core/index.html + test -s doc/build/html/autoapi/mlos_bench/index.html + test -s doc/build/html/autoapi/mlos_viz/index.html + test -s doc/build/html/autoapi/mlos_viz/dabl/index.html + grep -q -e '--config CONFIG' doc/build/html//mlos_bench.run.usage.html # Check doc logs for errors (but skip over some known ones) ... @cat doc/build/log.txt \ | egrep -C1 -e WARNING -e CRITICAL -e ERROR \ | egrep -v \ -e "warnings.warn\(f'\"{wd.path}\" is shallow and may cause errors'\)" \ -e "No such file or directory: '.*.examples'.( \[docutils\]\s*)?$$" \ - -e 'Problems with "include" directive path:' \ - -e 'duplicate object description' \ - -e "document isn't included in any toctree" \ - -e "more than one target found for cross-reference" \ -e "toctree contains reference to nonexisting document 'auto_examples/index'" \ - -e "failed to import function 'create' from module '(SpaceAdapter|Optimizer)Factory'" \ - -e "No module named '(SpaceAdapter|Optimizer)Factory'" \ -e '^make.*resetting jobserver mode' \ -e 'from cryptography.hazmat.primitives.ciphers.algorithms import' \ | grep -v '^\s*$$' \ @@ -798,7 +775,7 @@ build/linklint-doc.build-stamp: doc/build/html/index.html doc/build/html/htmlcov .PHONY: clean-doc clean-doc: - rm -rf doc/build/ doc/global/ doc/source/api/ doc/source/generated + rm -rf doc/build/ doc/global/ doc/source/api/ doc/source/generated doc/source/autoapi rm -rf doc/source/source_tree_docs/* .PHONY: clean-format diff --git a/README.md b/README.md index 2c1160dc938..dbc48ffb4fc 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,10 @@ Details on using a local version from git are available in [CONTRIBUTING.md](./C Working example of tuning `sqlite` with MLOS. -These can be used as starting points for new autotuning projects. +These can be used as starting points for new autotuning projects outside of the main MLOS repository if you want to keep your tuning experiment configs separate from the MLOS codebase. + +Alternatively, we accept PRs to add new examples to the main MLOS repository! +See [mlos_bench/config](./mlos_bench/mlos_bench/config/) and [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. ### Publications diff --git a/doc/README.md b/doc/README.md index eea9dd955c4..4ed884f8cc7 100644 --- a/doc/README.md +++ b/doc/README.md @@ -2,14 +2,99 @@ Documentation is generated using [`sphinx`](https://www.sphinx-doc.org/). +The configuration for this is in [`doc/source/conf.py`](./source/conf.py). + +We use the [`autoapi`](https://sphinx-autoapi.readthedocs.io/en/latest/) extension to generate documentation automatically from the docstrings in our python code. + +Additionally, we also use the [`copy-source-tree-docs.sh`](./copy-source-tree-docs.sh) script to copy a few Markdown files from the root of the repository to the `doc/source` build directory automatically to include them in the documentation. + +Those are included in the [`index.rst`](./source/index.rst) file which is the main entry point for the documentation and about the only manually maintained rst file. + +## Writing Documentation + +When writing docstrings, use the [`numpydoc`](https://numpydoc.readthedocs.io/en/latest/format.html) style. + +Where necessary embedded [reStructuredText (rst)](https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html) markup can be used to help format the documentation. + +Each top level module should include a docstring that describes the module and its purpose and usage. + +These string should be written for consumption by both users and developers. + +Other function and method docstrings that aren't typically intended for users can be written for developers. + +### Cross Referencing + +You can include links between the documentation using [cross-referencing](https://www.sphinx-doc.org/en/master/usage/domains/python.html#python-xref-roles) links in the docstring. + +For instance: + +```python +""" +My docstring that references another module :py:mod:`fully.qualified.module.name`. + +Or else, a class :py:class:`fully.qualified.module.name.ClassName`. + +Or else, a class name :py:class:`.ClassName` that is in the same module. + +Or else, a class method :py:meth:`~.ClassName.method` but without the leading class name. +""" +``` + +These links will be automatically resolved by `sphinx` and checked using the `nitpick` option to ensure we have well-formed links in the documentation. + +### Example Code + +Ideally, each main class should also inclue example code that demonstrates how to use the class. + +This code should be included in the docstring and should be runnable via [`doctest`](https://docs.python.org/3/library/doctest.html). + +For instance: + +```python +class MyClass: + """ + My class that does something. + + Examples + -------- + >>> from my_module import MyClass + >>> my_class = MyClass() + >>> my_class.do_something() + Expected output + + """ + ... +``` + +This code will be automatically checked with `pytest` using the `--doctest-modules` option specified in [`setup.cfg`](../setup.cfg). + +## Building the documentation + ```sh -make -C .. doc +# From the root of the repository +make SKIP_COVERAGE=true doc ``` -## Testing with Docker +This will also run some checks on the documentation. + +> When running this command in a tight loop, it may be useful to run with `SKIP_COVERAGE=true` to avoid re-running the test and coverage checks each time a python file changes. + +## Testing + +### Manually with Docker ```sh ./nginx-docker.sh restart ``` > Now browse to `http://localhost:8080` + +## Troubleshooting + +We use the [`intersphinx`](https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html) extension to link between external modules and the [`nitpick`](https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-nitpicky) option to ensure that all references resolve correctly. + +Unfortunately, this process is not perfect and sometimes we need to provide [`nitpick_ignore`](https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-nitpick_ignore)s in the [`doc/source/conf.py`](./source/conf.py) file. + +In particular, currently external `TypeVar` and `TypeAliases` are not resolved correctly and we need to ignore those. + +In other cases, specifying the full path to the module in the cross-reference or the `import` can help. diff --git a/doc/copy-source-tree-docs.sh b/doc/copy-source-tree-docs.sh index 0eeac02fdc3..027a7448e70 100755 --- a/doc/copy-source-tree-docs.sh +++ b/doc/copy-source-tree-docs.sh @@ -24,6 +24,7 @@ for readme_file_path in README.md mlos_core/README.md mlos_bench/README.md mlos_ cp "$readme_file_path" "doc/source/source_tree_docs/$file_dir/index.md" # Tweak source source code links. + # FIXME: This sed expression doesn't work in MacOS. sed -i -r -e "s|\]\(([^:#)]+)(#[a-zA-Z0-9_-]+)?\)|\]\(https://github.com/microsoft/MLOS/tree/main/$file_dir/\1\2\)|g" \ "doc/source/source_tree_docs/$file_dir/index.md" # Tweak the lexers for local expansion by pygments instead of github's. diff --git a/doc/requirements.txt b/doc/requirements.txt index 9825b5b442a..fac2db1fa4c 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,10 +1,11 @@ setuptools-scm>=8.1.0 sphinx +sphinx-autoapi +intersphinx_registry>=0.2410.14 # https://github.com/Quansight-Labs/intersphinx_registry/pull/41 nbsphinx jupyter_core>=4.11.2 # nbsphix dependency - addresses CVE-2022-39286 nbconvert mistune>=2.0.3 # Address CVE-2022-34749 -numpydoc sphinx-rtd-theme myst-parser diff --git a/doc/source/.gitignore b/doc/source/.gitignore index bf83a1d89cf..8173208e1d5 100644 --- a/doc/source/.gitignore +++ b/doc/source/.gitignore @@ -1,4 +1,5 @@ api/ +autoapi/ generated/ badges/ source_tree_docs/ diff --git a/doc/source/_templates/class.rst b/doc/source/_templates/class.rst deleted file mode 100644 index 3eef9746722..00000000000 --- a/doc/source/_templates/class.rst +++ /dev/null @@ -1,16 +0,0 @@ -:mod:`{{module}}`.{{objname}} -{{ underline }}============== - -.. currentmodule:: {{ module }} - -.. autoclass:: {{ objname }} - - {% block methods %} - .. automethod:: __init__ - {% endblock %} - -.. include:: {{module}}.{{objname}}.examples - -.. raw:: html - -
diff --git a/doc/source/_templates/function.rst b/doc/source/_templates/function.rst deleted file mode 100644 index 4ba355d57c8..00000000000 --- a/doc/source/_templates/function.rst +++ /dev/null @@ -1,12 +0,0 @@ -:mod:`{{module}}`.{{objname}} -{{ underline }}==================== - -.. currentmodule:: {{ module }} - -.. autofunction:: {{ objname }} - -.. include:: {{module}}.{{objname}}.examples - -.. raw:: html - -
diff --git a/doc/source/_templates/numpydoc_docstring.py b/doc/source/_templates/numpydoc_docstring.py deleted file mode 100644 index 71acc5df9f0..00000000000 --- a/doc/source/_templates/numpydoc_docstring.py +++ /dev/null @@ -1,20 +0,0 @@ -# -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# -{{index}} -{{summary}} -{{extended_summary}} -{{parameters}} -{{returns}} -{{yields}} -{{other_parameters}} -{{attributes}} -{{raises}} -{{warns}} -{{warnings}} -{{see_also}} -{{notes}} -{{references}} -{{examples}} -{{methods}} diff --git a/doc/source/conf.py b/doc/source/conf.py index 42459f4de92..5a3771714f9 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -18,13 +18,18 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. # +import json import os import sys +from typing import Dict, Union, Tuple from logging import warning -import sphinx_rtd_theme # pylint: disable=unused-import - +from docutils.nodes import Element +from intersphinx_registry import get_intersphinx_mapping +from sphinx.application import Sphinx as SphinxApp +from sphinx.environment import BuildEnvironment +from sphinx.addnodes import pending_xref sys.path.insert(0, os.path.abspath("../../mlos_core/mlos_core")) sys.path.insert(1, os.path.abspath("../../mlos_bench/mlos_bench")) @@ -63,17 +68,163 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - "nbsphinx", "sphinx.ext.autodoc", - "sphinx.ext.autosummary", - "sphinx.ext.doctest", - # 'sphinx.ext.intersphinx', - # 'sphinx.ext.linkcode', - "numpydoc", + "autoapi.extension", + "nbsphinx", + "sphinx.ext.intersphinx", + "sphinx.ext.linkcode", + "sphinx.ext.napoleon", "matplotlib.sphinxext.plot_directive", "myst_parser", ] +autodoc_typehints = "both" # signature and description + +napoleon_numpy_docstring = True +napoleon_include_init_with_doc = False +napoleon_include_private_with_doc = False +napoleon_include_special_with_doc = True +napoleon_use_admonition_for_examples = False +napoleon_use_admonition_for_notes = False +napoleon_use_admonition_for_references = False +napoleon_use_ivar = False +napoleon_use_param = True +napoleon_use_rtype = True +napoleon_use_keyword = True +napoleon_custom_sections = None + +_base_path = os.path.abspath(os.path.join(__file__, "../../..")) +_path_cache: Dict[str, bool] = {} + + +def _check_path(path: str) -> bool: + """Check if a path exists and cache the result.""" + path = os.path.join(_base_path, path) + result = _path_cache.get(path) + if result is None: + result = os.path.exists(path) + _path_cache[path] = result + return result + + +def linkcode_resolve(domain: str, info: Dict[str, str]): + """linkcode extension override to link to the source code on GitHub.""" + if domain != "py": + return None + if not info["module"]: + return None + if not info["module"].startswith("mlos_"): + return None + package = info["module"].split(".")[0] + filename = info["module"].replace(".", "/") + path = f"{package}/{filename}.py" + if not _check_path(path): + path = f"{package}/{filename}/__init__.py" + if not _check_path(path): + warning(f"linkcode_resolve failed to find {path}") + warning(f"linkcode_resolve info: {json.dumps(info, indent=2)}") + return f"https://github.com/microsoft/MLOS/tree/main/{path}" + + +def is_on_github_actions(): + """Check if the documentation is being built on GitHub Actions.""" + return os.environ.get("CI") and os.environ.get("GITHUB_RUN_ID") + + +# Add mappings to link to external documentation. +intersphinx_mapping = get_intersphinx_mapping( + packages={ + "asyncssh", + "azure-core", + "azure-identity", + "configspace", + "matplotlib", + "numpy", + "pandas", + "python", + "referencing", + "smac", + "typing_extensions", + } +) +intersphinx_mapping.update( + { + "dabl": ("https://dabl.github.io/stable/", None), + } +) + +# Hack to resolve type aliases as attributes instead of classes. +# See Also: https://github.com/sphinx-doc/sphinx/issues/10785 + +# Type alias resolution map +# (original, refname) -> new +CUSTOM_REF_TYPE_MAP: Dict[Tuple[str, str], str] = { + # Internal typevars and aliases: + ("BaseTypeVar", "class"): "data", + ("ConcreteOptimizer", "class"): "data", + ("ConcreteSpaceAdapter", "class"): "data", + ("DistributionName", "class"): "data", + ("FlamlDomain", "class"): "data", + ("mlos_core.spaces.converters.flaml.FlamlDomain", "class"): "data", + ("TunableValue", "class"): "data", + ("mlos_bench.tunables.tunable.TunableValue", "class"): "data", + ("TunableValueType", "class"): "data", + ("TunableValueTypeName", "class"): "data", + ("T_co", "class"): "data", + ("CoroReturnType", "class"): "data", + ("FutureReturnType", "class"): "data", +} + + +def resolve_type_aliases( + app: SphinxApp, + env: BuildEnvironment, + node: pending_xref, + contnode: Element, +) -> Optional[Element]: + """Resolve :class: references to our type aliases as :attr: instead.""" + if node["refdomain"] != "py": + return None + (orig_type, reftarget) = (node["reftype"], node["reftarget"]) + new_type = CUSTOM_REF_TYPE_MAP.get((reftarget, orig_type)) + if new_type: + # warning(f"Resolved {orig_type} {reftarget} to {new_type}") + return app.env.get_domain("py").resolve_xref( + env, + node["refdoc"], + app.builder, + new_type, + reftarget, + node, + contnode, + ) + return None + + +def setup(app: SphinxApp) -> None: + """Connect the missing-reference event to resolve type aliases.""" + app.connect("missing-reference", resolve_type_aliases) + + +# Ignore some cross references to external things we can't intersphinx with. +# sphinx has a hard time finding typealiases and typevars instead of classes. +# See Also: https://github.com/sphinx-doc/sphinx/issues/10974 +nitpick_ignore = [ + # Internal typevars and aliases: + ("py:class", "EnvironType"), + # External typevars and aliases: + ("py:class", "numpy.typing.NDArray"), + # External classes that refuse to resolve: + ("py:class", "contextlib.nullcontext"), + ("py:class", "sqlalchemy.engine.Engine"), + ("py:exc", "jsonschema.exceptions.SchemaError"), + ("py:exc", "jsonschema.exceptions.ValidationError"), +] +nitpick_ignore_regex = [ + # Ignore some external references that don't use sphinx for their docs. + (r"py:.*", r"flaml\..*"), +] +# Which documents to include in the build. source_suffix = { ".rst": "restructuredtext", # '.txt': 'markdown', @@ -81,21 +232,7 @@ } # Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# generate autosummary even if no references -autosummary_generate = True -# but don't complain about missing stub files -# See Also: -numpydoc_class_members_toctree = False - -autodoc_default_options = { - "members": True, - "undoc-members": True, - # Don't generate documentation for some (non-private) functions that are more - # for internal implementation use. - "exclude-members": "mlos_bench.util.check_required_params", -} +# templates_path = ["_templates"] # Generate the plots for the gallery # plot_gallery = True @@ -105,6 +242,40 @@ # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ["_build", "_templates"] +autoapi_dirs = [ + # Don't index setup.py or other utility scripts. + "../../mlos_core/mlos_core/", + "../../mlos_bench/mlos_bench/", + "../../mlos_viz/mlos_viz/", +] +autoapi_ignore = [ + "*/tests/*", + # Don't document internal environment scripts that aren't part of a module. + "*/mlos_bench/config/environments/*/*.py", + "*/mlos_bench/config/services/*/*.py", +] +autoapi_options = [ + "members", + # Can't document externally inherited members due to broken references. + # "inherited-members", + "undoc-members", + # Don't document private members. + # "private-members", + "show-inheritance", + # Causes issues when base class is a typing protocol. + # "show-inheritance-diagram", + "show-module-summary", + "special-members", + # Causes duplicate reference issues. For instance: + # - mlos_bench.environments.LocalEnv + # - mlos_bench.environments.local.LocalEnv + # - mlos_bench.environments.local.local_env.LocalEnv + # "imported-members", +] +autoapi_python_class_content = "both" +autoapi_member_order = "groupwise" +autoapi_add_toctree_entry = False # handled manually +autoapi_keep_files = not is_on_github_actions() # for local testing # -- Options for HTML output ------------------------------------------------- @@ -112,7 +283,6 @@ # a list of builtin themes. # html_theme = "sphinx_rtd_theme" -# html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/doc/source/index.rst b/doc/source/index.rst index e0302b9f78a..68bd53dbef9 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -1,43 +1,42 @@ -Welcome to the MLOS documentation! -================================== +MLOS Documentation +================== .. image:: badges/tests.svg .. image:: badges/coverage.svg :target: htmlcov/index.html -`MLOS `_ is a project to enable autotuning for systems via automated benchmarking including managing the storage and visualization of the results. +`MLOS `_ is a project to enable autotuning for systems via `automated benchmarking `_ including managing the storage and `visualization `_ of the results. See below for additional documentation sections. +Here is some documentation pulled from the markdown files in the `MLOS source tree `_: + .. toctree:: - :maxdepth: 2 :caption: Source Tree Documentation + :maxdepth: 5 source_tree_docs/index source_tree_docs/mlos_core/index source_tree_docs/mlos_bench/index source_tree_docs/mlos_viz/index -.. toctree:: - :maxdepth: 2 - :caption: API Overview - - overview +Here is some documentation pulled from the Python docstrings in the `MLOS source tree `_: .. toctree:: - :maxdepth: 3 :caption: API Reference + :titlesonly: + :maxdepth: 5 - api/mlos_core/modules - api/mlos_bench/modules - api/mlos_viz/modules + autoapi/mlos_core/index + autoapi/mlos_bench/index + autoapi/mlos_viz/index .. toctree:: - :maxdepth: 2 - :caption: Examples + :caption: mlos_bench CLI usage + :maxdepth: 1 - auto_examples/index + mlos_bench.run.usage .. toctree:: :maxdepth: 1 diff --git a/doc/source/mlos_bench.run.usage.rst b/doc/source/mlos_bench.run.usage.rst new file mode 100644 index 00000000000..513d2274db5 --- /dev/null +++ b/doc/source/mlos_bench.run.usage.rst @@ -0,0 +1,10 @@ +mlos_bench CLI usage +==================== + +Here is the current ``--help`` output for the ``mlos_bench`` :py:mod:`CLI script `: + +See the :py:mod:`mlos_bench.config` module documentation for more information on +configuration files. + +.. literalinclude:: ./generated/mlos_bench.run.usage.txt + :language: none diff --git a/doc/source/overview.rst b/doc/source/overview.rst deleted file mode 100644 index b3ca2a3fad3..00000000000 --- a/doc/source/overview.rst +++ /dev/null @@ -1,298 +0,0 @@ -########################## -MLOS Package APIs Overview -########################## - -This is a list of major functions and classes provided by the MLOS packages. - -############################# -mlos-core API -############################# - -This is a list of major functions and classes provided by `mlos_core`. - -.. currentmodule:: mlos_core - -Optimizers -========== -.. currentmodule:: mlos_core.optimizers -.. autosummary:: - :toctree: generated/ - - :template: class.rst - - OptimizerType - OptimizerFactory - - :template: function.rst - - OptimizerFactory.create - -.. currentmodule:: mlos_core.optimizers.optimizer -.. autosummary:: - :toctree: generated/ - :template: class.rst - - BaseOptimizer - -.. currentmodule:: mlos_core.optimizers.random_optimizer -.. autosummary:: - :toctree: generated/ - :template: class.rst - - RandomOptimizer - -.. currentmodule:: mlos_core.optimizers.flaml_optimizer -.. autosummary:: - :toctree: generated/ - :template: class.rst - - FlamlOptimizer - -.. currentmodule:: mlos_core.optimizers.bayesian_optimizers -.. autosummary:: - :toctree: generated/ - :template: class.rst - - BaseBayesianOptimizer - SmacOptimizer - -Spaces -====== - -Converters ----------- -.. currentmodule:: mlos_core.spaces.converters.flaml -.. autosummary:: - :toctree: generated/ - :template: function.rst - - configspace_to_flaml_space - -Space Adapters --------------- -.. currentmodule:: mlos_core.spaces.adapters -.. autosummary:: - :toctree: generated/ - - :template: class.rst - - SpaceAdapterType - SpaceAdapterFactory - - :template: function.rst - - SpaceAdapterFactory.create - -.. currentmodule:: mlos_core.spaces.adapters.adapter -.. autosummary:: - :toctree: generated/ - :template: class.rst - - BaseSpaceAdapter - -.. currentmodule:: mlos_core.spaces.adapters.identity_adapter -.. autosummary:: - :toctree: generated/ - :template: class.rst - - IdentityAdapter - -.. currentmodule:: mlos_core.spaces.adapters.llamatune -.. autosummary:: - :toctree: generated/ - :template: class.rst - - LlamaTuneAdapter - -############################# -mlos-bench API -############################# - -This is a list of major functions and classes provided by `mlos_bench`. - -.. currentmodule:: mlos_bench - -Main -==== - -:doc:`run.py ` - - The script to run the benchmarks or the optimization loop. - - Also available as `mlos_bench` command line tool. - -.. note:: - The are `json config examples `_ and `json schemas `_ on the main `source code `_ repository site. - -Benchmark Environments -====================== -.. currentmodule:: mlos_bench.environments -.. autosummary:: - :toctree: generated/ - :template: class.rst - - Status - Environment - CompositeEnv - MockEnv - -Local Environments -------------------- - -.. currentmodule:: mlos_bench.environments.local -.. autosummary:: - :toctree: generated/ - :template: class.rst - - LocalEnv - LocalFileShareEnv - -Remote Environments -------------------- - -.. currentmodule:: mlos_bench.environments.remote -.. autosummary:: - :toctree: generated/ - :template: class.rst - - RemoteEnv - OSEnv - VMEnv - HostEnv - -Tunable Parameters -================== -.. currentmodule:: mlos_bench.tunables -.. autosummary:: - :toctree: generated/ - :template: class.rst - - Tunable - TunableGroups - -Service Mix-ins -=============== -.. currentmodule:: mlos_bench.services -.. autosummary:: - :toctree: generated/ - :template: class.rst - - Service - FileShareService - -.. currentmodule:: mlos_bench.services.config_persistence -.. autosummary:: - :toctree: generated/ - :template: class.rst - - ConfigPersistenceService - -Local Services ---------------- -.. currentmodule:: mlos_bench.services.local -.. autosummary:: - :toctree: generated/ - :template: class.rst - - LocalExecService - -Remote Azure Services ---------------------- - -.. currentmodule:: mlos_bench.services.remote.azure -.. autosummary:: - :toctree: generated/ - :template: class.rst - - AzureVMService - AzureFileShareService - -Optimizer Adapters -================== -.. currentmodule:: mlos_bench.optimizers -.. autosummary:: - :toctree: generated/ - :template: class.rst - - Optimizer - MockOptimizer - MlosCoreOptimizer - -Storage -======= -Base Runtime Backends ---------------------- -.. currentmodule:: mlos_bench.storage -.. autosummary:: - :toctree: generated/ - :template: class.rst - - Storage - -.. currentmodule:: mlos_bench.storage.storage_factory -.. autosummary:: - :toctree: generated/ - :template: function.rst - - from_config - -SQL DB Storage Backend ----------------------- -.. currentmodule:: mlos_bench.storage.sql.storage -.. autosummary:: - :toctree: generated/ - :template: class.rst - - SqlStorage - -Analysis Client Access APIs ---------------------------- -.. currentmodule:: mlos_bench.storage.base_experiment_data -.. autosummary:: - :toctree: generated/ - :template: class.rst - - ExperimentData - -.. currentmodule:: mlos_bench.storage.base_trial_data -.. autosummary:: - :toctree: generated/ - :template: class.rst - - TrialData - -.. currentmodule:: mlos_bench.storage.base_tunable_config_data -.. autosummary:: - :toctree: generated/ - :template: class.rst - - TunableConfigData - -.. currentmodule:: mlos_bench.storage.base_tunable_config_trial_group_data -.. autosummary:: - :toctree: generated/ - :template: class.rst - - TunableConfigTrialGroupData - -############################# -mlos-viz API -############################# - -This is a list of major functions and classes provided by `mlos_viz`. - -.. currentmodule:: mlos_viz - -.. currentmodule:: mlos_viz -.. autosummary:: - :toctree: generated/ - :template: class.rst - - MlosVizMethod - -.. currentmodule:: mlos_viz -.. autosummary:: - :toctree: generated/ - :template: function.rst - - plot diff --git a/mlos_bench/mlos_bench/__init__.py b/mlos_bench/mlos_bench/__init__.py index d0214754411..1f54f78c440 100644 --- a/mlos_bench/mlos_bench/__init__.py +++ b/mlos_bench/mlos_bench/__init__.py @@ -2,9 +2,131 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""mlos_bench is a framework to help automate benchmarking and and OS/application -parameter autotuning. -""" +r""" +mlos_bench is a framework to help automate benchmarking and OS/application parameter +autotuning and the data management of the results. + +It can be installed from `pypi `_ via ``pip +install mlos-bench`` and executed using the ``mlos_bench`` `command +<../../mlos_bench.run.usage.html>`_ using a collection of `json` `configs +`_. + +It is intended to be used with :py:mod:`mlos_core` via +:py:class:`~mlos_bench.optimizers.mlos_core_optimizer.MlosCoreOptimizer` to help +navigate complex parameter spaces more effeciently, though other +:py:mod:`~mlos_bench.optimizers` are also available to help customize the search +process easily by simply swapping out the +:py:class:`~mlos_bench.optimizers.base_optimizer.Optimizer` class in the associated +json configs. For instance, +:py:class:`~mlos_bench.optimizers.grid_search_optimizer.GridSearchOptimizer` can be +used to perform a grid search over the parameter space instead. + +The other core classes in this package are: + +- :py:mod:`~mlos_bench.environments` which provide abstractions for representing an + execution environment. + + These are generally the target of the optimization process and are used to + evaluate the performance of a given configuration, though can also be used to + simply run a single benchmark. They can be used, for instance, to provision a + :py:mod:`VM `, run benchmarks or execute + any other arbitrary code on a :py:mod:`remote machine + `, and many other things. + +- Environments are often associated with :py:mod:`~mlos_bench.tunables` which + provide a language for specifying the set of configuration parameters that can be + optimized or searched over with the :py:mod:`~mlos_bench.optimizers`. + +- :py:mod:`~mlos_bench.services` provide the necessary abstractions to run interact + with the :py:mod:`~mlos_bench.environments` in different settings. + + For instance, the + :py:class:`~mlos_bench.services.remote.azure.azure_vm_services.AzureVMService` can + be used to run commands on Azure VMs for a remote + :py:mod:`~mlos_bench.environments.remote.vm_env.VMEnv`. + + Alternatively, one could swap out that service for + :py:class:`~mlos_bench.services.remote.ssh.ssh_host_service.SshHostService` in + order to target a different VM without having to change the + :py:class:`~mlos_bench.environments.base_environment.Environment` configuration at + all since they both implement the same + :py:class:`~mlos_bench.services.types.remote_exec_type.SupportsRemoteExec` + :py:mod:`Services type` interfaces. + + This is particularly useful when running the same benchmark on different + ecosystems and makes the configs more modular and composable. + +- :py:mod:`~mlos_bench.storage` which provides abstractions for storing and + retrieving data from the experiments. + + For instance, nearly any :py:mod:`SQL ` backend that + `sqlalchemy `_ supports can be used. + +The data management and automation portions of experiment data is a key component of +MLOS as it provides a unified way to manage experiment data across different +Environments, enabling more reusable visualization and analysis by mapping benchmark +metrics into common semantic types (e.g., via `OpenTelemetry +`_). + +Without this most experiments are effectively siloed and require custom, and more +critically, non-reusable scripts to setup and later parse results and are hence +harder to scale to many users. + +With these features as a part of the MLOS ecosystem, benchmarking can become a +*service* that any developer, admin, research, etc. can use and adapt. + +See below for more information on the classes in this package. + +Notes +----- +Note that while the docstrings in this package are generated from the source code +and hence sometimes more focused on the implementation details, most user +interactions with the package will be through the `json configs +`_. Even +so it may be useful to look at the source code to understand how those are +interpretted. + +Examples +-------- +Here is an example that shows how to run a simple benchmark using the command line. + +The entry point for these configs can be found `here +`_. + +>>> from subprocess import run +>>> # Note: we show the command wrapped in python here for testing purposes. +>>> # Alternatively replace test-cli-local-env-bench.jsonc with +>>> # test-cli-local-env-opt.jsonc for one that does an optimization loop. +>>> cmd = "mlos_bench \ +... --config mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-bench.jsonc \ +... --globals experiment_test_local.jsonc \ +... --tunable_values tunable-values/tunable-values-local.jsonc" +>>> print(f"Here's the shell command you'd actually run:\n# {cmd}") +Here's the shell command you'd actually run: +# mlos_bench --config mlos_bench/mlos_bench/tests/config/cli/test-cli-local-env-bench.jsonc --globals experiment_test_local.jsonc --tunable_values tunable-values/tunable-values-local.jsonc +>>> # Now we run the command and check the output. +>>> result = run(cmd, shell=True, capture_output=True, text=True, check=True) +>>> assert result.returncode == 0 +>>> lines = result.stderr.splitlines() +>>> first_line = lines[0] +>>> last_line = lines[-1] +>>> expected = "INFO Launch: mlos_bench" +>>> assert first_line.endswith(expected) +>>> expected = "INFO Final score: {'score': 123.4, 'total_time': 123.4, 'throughput': 1234567.0}" +>>> assert last_line.endswith(expected) + +Notes +----- +- `mlos_bench/README.md `_ + for additional documentation and examples in the source tree. + +- `mlos_bench/DEVNOTES.md `_ + for additional developer notes in the source tree. + +- There is also a working example of using ``mlos_bench`` in a *separate config + repo* (the more expected case for most users) in the `sqlite-autotuning + `_ repo. +""" # pylint: disable=line-too-long # noqa: E501 from mlos_bench.version import VERSION __version__ = VERSION diff --git a/mlos_bench/mlos_bench/config/__init__.py b/mlos_bench/mlos_bench/config/__init__.py index b78386118c6..f19cf95b32c 100644 --- a/mlos_bench/mlos_bench/config/__init__.py +++ b/mlos_bench/mlos_bench/config/__init__.py @@ -2,4 +2,262 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""mlos_bench.config.""" +""" +A module for and documentation about the structure and mangement of json configs, their +schemas and validation for various components of MLOS. + +.. contents:: Table of Contents + :depth: 3 + +Overview +++++++++ + +MLOS is a framework for doing benchmarking and autotuning for systems. +The bulk of the code to do that is written in python. As such, all of the code +classes documented here take python objects in their construction. + +However, most users of MLOS will interact with the system via the ``mlos_bench`` CLI +and its json config files and their own scripts for MLOS to invoke. This module +attempts to document some of those high level interactions. + +General JSON Config Structure ++++++++++++++++++++++++++++++ + +We use `json5 `_ to parse the json files, since it +allows for inline C style comments (e.g., ``//``, ``/* */``), trailing commas, etc., +so it is slightly more user friendly than strict json. + +By convention files use the ``*.mlos.json`` or ``*.mlos.jsonc`` extension to +indicate that they are an ``mlos_bench`` config file. + +This allows tools that support `JSON Schema Store +`_ (e.g., `VSCode +`_ with an `extension +`_) to +provide helpful autocomplete and validation of the json configs while editing. + +CLI Configs +^^^^^^^^^^^ + +:py:attr:`~.mlos_bench.config.schemas.config_schemas.ConfigSchema.CLI` style configs +are typically used to start the ``mlos_bench`` CLI using the ``--config`` argument +and a restricted key-value dict form where each key corresponds to a CLI argument. + +For instance: + +.. code-block:: json + + // cli-config.mlos.json + { + "experiment": "path/to/base/experiment-config.mlos.json", + "services": [ + "path/to/some/service-config.mlos.json", + ], + "globals": "path/to/basic-globals-config.mlos.json", + } + +.. code-block:: json + + // basic-globals-config.mlos.json + { + "location": "westus", + "vm_size": "Standard_D2s_v5", + } + +Typically CLI configs will reference some other configs, especially the base +Environment and Services configs, but some ``globals`` may be left to be specified +on the command line. + +For instance: + +.. code-block:: shell + + mlos_bench --config path/to/cli-config.mlos.json --globals experiment-config.mlos.json + +where ``experiment-config.mlos.json`` might look something like this: + +.. code-block:: json + + // experiment-config.mlos.json (also a set of globals) + { + "experiment_id": "my_experiment", + "some_var": "some_value", + } + +This allows some of the ``globals`` to be specified on the CLI to alter the behavior +of a set of Experiments without having to adjust many of the other config files +themselves. + +See below for examples. + +Notes +----- +- See `mlos_bench CLI usage `_ for more details on the + CLI arguments. +- See `mlos_bench/config/cli + `_ + and `mlos_bench/tests/config/cli + `_ + for some examples of CLI configs. + +Globals and Variable Substitution ++++++++++++++++++++++++++++++++++ + +:py:attr:`Globals ` +are basically just key-value variables that can be used in other configs using +``$variable`` substituion via the +:py:meth:`~mlos_bench.dict_templater.DictTemplater.expand_vars` method. + +For instance: + +.. code-block:: json + + // globals-config.mlos.json + { + "experiment_id": "my_experiment", + "some_var": "some_value", + // environment variable expansion also works here + "current_dir": "$PWD", + "some_expanded_var": "$some_var: $experiment_id", + "location": "eastus", + } + +There are additional details about variable propogation in the +:py:mod:`mlos_bench.environments` module. + +Well Known Variables +^^^^^^^^^^^^^^^^^^^^ + +Here is a list of some well known variables that are provided or required by the +system and may be used in the config files: + +- ``$experiment_id``: A unique identifier for the experiment. + Typically provided in globals. +- ``$trial_id``: A unique identifier for the trial currently being executed. + This can be useful in the configs for :py:mod:`mlos_bench.environments` for + instance (e.g., when writing scripts). +- TODO: Document more variables here. + +Tunable Configs +^^^^^^^^^^^^^^^ + +There are two forms of tunable configs: + +- "TunableParams" style configs + + Which are used to define the set of + :py:mod:`~mlos_bench.tunables.tunable_groups.TunableGroups` (i.e., tunable + parameters). + + .. code-block:: json + + // some-env-tunables.json + { + // a group of tunables that are tuned together + "covariant_group_name": [ + { + "name": "tunable_name", + "type": "int", + "range": [0, 100], + "default": 50, + }, + // more tunables + ], + // another group of tunables + // both can be enabled at the same time + "another_group_name": [ + { + "name": "another_tunable_name", + "type": "categorical", + "values": ["red", "yellow", "green"], + "default": "green" + }, + // more tunables + ], + } + + Since TunableParams are associated with Environments, they are typically kept + in the same directory as that environment and named something like + ``env-tunables.json``. + +- "TunableValues" style configs which are used to specify the values for an + instantiation of a set of tunables params. + + These are essentially just a dict of the tunable names and their values. + For instance: + + .. code-block:: json + + // tunable-values.mlos.json + { + "tunable_name": 25, + "another_tunable_name": "red", + } + + These can be used with the + :py:class:`~mlos_bench.optimizers.one_shot_optimizer.OneShotOptimizer` + :py:class:`~mlos_bench.optimizers.manual_optimizer.ManualOptimizer` to run a + benchmark with a particular config or set of configs. + +Class Configs +^^^^^^^^^^^^^ + +Class style configs include most anything else and roughly take this form: + +.. code-block:: json + + // class configs (environments, services, etc.) + { + // some mlos class name to load + "class": "mlos_bench.type.ClassName", + "config": { + // class specific config + "key": "value", + "key2": "$some_var", // variable substitution is allowed here too + } + } + +Where ``type`` is one of the core classes in the system: + +- :py:mod:`~mlos_bench.environments` +- :py:mod:`~mlos_bench.optimizers` +- :py:mod:`~mlos_bench.services` +- :py:mod:`~mlos_bench.schedulers` +- :py:mod:`~mlos_bench.storage` + +Each of which have their own submodules and classes that dictate the allowed and +expected structure of the ``config`` section. + +In certain cases (e.g., script command execution) the variable substitution rules +take on slightly different behavior +See various documentation in :py:mod:`mlos_bench.environments` for more details. + +Config Processing ++++++++++++++++++ + +Config files are processed by the :py:class:`~mlos_bench.launcher.Launcher` and +:py:class:`~mlos_bench.services.config_persistence.ConfigPersistenceService` classes +at startup time by the ``mlos_bench`` CLI. + +The typical entrypoint is a CLI config which references other configs, especially +the base Environment config, Services, Optimizer, and Storage. + +See `mlos_bench CLI usage `_ for more details on those +arguments. + +Schema Definitions +++++++++++++++++++ + +For further details on the schema definitions and validation, see the +:py:class:`~mlos_bench.config.schemas.config_schemas.ConfigSchema` class +documentation, which also contains links to the actual schema definitions in the +source tree (see below). + +Notes +----- +See `mlos_bench/config/README.md +`_ and +`mlos_bench/tests/config/README.md +`_ +for additional documentation and examples in the source tree. +""" diff --git a/mlos_bench/mlos_bench/config/environments/os/linux/boot/scripts/local/create_new_grub_cfg.py b/mlos_bench/mlos_bench/config/environments/os/linux/boot/scripts/local/create_new_grub_cfg.py index 9b75f040080..c0cc5b3ea84 100755 --- a/mlos_bench/mlos_bench/config/environments/os/linux/boot/scripts/local/create_new_grub_cfg.py +++ b/mlos_bench/mlos_bench/config/environments/os/linux/boot/scripts/local/create_new_grub_cfg.py @@ -14,10 +14,16 @@ JSON_CONFIG_FILE = "config-boot-time.json" NEW_CFG = "zz-mlos-boot-params.cfg" -with open(JSON_CONFIG_FILE, "r", encoding="UTF-8") as fh_json, open( - NEW_CFG, "w", encoding="UTF-8" -) as fh_config: - for key, val in json.load(fh_json).items(): - fh_config.write( - 'GRUB_CMDLINE_LINUX_DEFAULT="$' f'{{GRUB_CMDLINE_LINUX_DEFAULT}} {key}={val}"\n' - ) + +def _write_config() -> None: + with open(JSON_CONFIG_FILE, "r", encoding="UTF-8") as fh_json, open( + NEW_CFG, "w", encoding="UTF-8" + ) as fh_config: + for key, val in json.load(fh_json).items(): + fh_config.write( + 'GRUB_CMDLINE_LINUX_DEFAULT="$' f'{{GRUB_CMDLINE_LINUX_DEFAULT}} {key}={val}"\n' + ) + + +if __name__ == "__main__": + _write_config() diff --git a/mlos_bench/mlos_bench/config/schemas/__init__.py b/mlos_bench/mlos_bench/config/schemas/__init__.py index d4987add630..9f34906a76d 100644 --- a/mlos_bench/mlos_bench/config/schemas/__init__.py +++ b/mlos_bench/mlos_bench/config/schemas/__init__.py @@ -2,7 +2,14 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""A module for managing config schemas and their validation.""" +""" +A module for managing config schemas and their validation. + +See Also +-------- +mlos_bench.config.schemas.config_schemas : The module handling the actual schema + definitions and validation. +""" from mlos_bench.config.schemas.config_schemas import CONFIG_SCHEMA_DIR, ConfigSchema diff --git a/mlos_bench/mlos_bench/config/schemas/config_schemas.py b/mlos_bench/mlos_bench/config/schemas/config_schemas.py index b7ce402b5d5..402f96a8b9f 100644 --- a/mlos_bench/mlos_bench/config/schemas/config_schemas.py +++ b/mlos_bench/mlos_bench/config/schemas/config_schemas.py @@ -2,8 +2,23 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""A simple class for describing where to find different config schemas and validating -configs against them. +""" +A simple class for describing where to find different `json config schemas +`_ and validating configs against them. + +Used by the :py:class:`~mlos_bench.launcher.Launcher` and +:py:class:`~mlos_bench.services.config_persistence.ConfigPersistenceService` to +validate configs on load. + +Notes +----- +- See `mlos_bench/config/schemas/README.md + `_ + for additional documentation in the source tree. + +- See `mlos_bench/config/README.md + `_ + for additional config examples in the source tree. """ import json # schema files are pure json - no comments @@ -22,13 +37,23 @@ # The path to find all config schemas. CONFIG_SCHEMA_DIR = path_join(path.dirname(__file__), abs_path=True) +"""The local directory where all config schemas shipped as a part of the +:py:mod:`mlos_bench` module are stored. +""" # Allow skipping schema validation for tight dev cycle changes. # It is used in `ConfigSchema.validate()` method below. # NOTE: this may cause pytest to fail if it's expecting exceptions # to be raised for invalid configs. -_VALIDATION_ENV_FLAG = "MLOS_BENCH_SKIP_SCHEMA_VALIDATION" -_SKIP_VALIDATION = environ.get(_VALIDATION_ENV_FLAG, "false").lower() in { +VALIDATION_ENV_FLAG = "MLOS_BENCH_SKIP_SCHEMA_VALIDATION" +""" +The special environment flag to set to skip schema validation when "true". + +Useful for local development when you're making a lot of changes to the config or adding +new classes that aren't in the main repo yet. +""" + +_SKIP_VALIDATION = environ.get(VALIDATION_ENV_FLAG, "false").lower() in { "true", "y", "yes", @@ -105,22 +130,96 @@ def registry(self) -> Registry: SCHEMA_STORE = SchemaStore() +"""Static :py:class:`.SchemaStore` instance used for storing and retrieving schemas for +config validation. +""" class ConfigSchema(Enum): """An enum to help describe schema types and help validate configs against them.""" CLI = path_join(CONFIG_SCHEMA_DIR, "cli/cli-schema.json") + """ + Json config `schema + `__ + for :py:mod:`mlos_bench ` CLI configuration. + + See Also + -------- + mlos_bench.config : documentation on the configuration system. + mlos_bench.launcher.Launcher : class is responsible for processing the CLI args. + """ + GLOBALS = path_join(CONFIG_SCHEMA_DIR, "cli/globals-schema.json") + """ + Json config `schema + `__ + for :py:mod:`global variables `. + """ + ENVIRONMENT = path_join(CONFIG_SCHEMA_DIR, "environments/environment-schema.json") + """ + Json config `schema + `__ + for :py:mod:`~mlos_bench.environments`. + """ + OPTIMIZER = path_join(CONFIG_SCHEMA_DIR, "optimizers/optimizer-schema.json") + """ + Json config `schema + `__ + for :py:mod:`~mlos_bench.optimizers`. + """ + SCHEDULER = path_join(CONFIG_SCHEMA_DIR, "schedulers/scheduler-schema.json") + """ + Json config `schema + `__ + for :py:mod:`~mlos_bench.schedulers`. + """ + SERVICE = path_join(CONFIG_SCHEMA_DIR, "services/service-schema.json") + """ + Json config `schema + `__ + for :py:mod:`~mlos_bench.services`. + """ + STORAGE = path_join(CONFIG_SCHEMA_DIR, "storage/storage-schema.json") + """ + Json config `schema + `__ + for :py:mod:`~mlos_bench.storage` instances. + """ + TUNABLE_PARAMS = path_join(CONFIG_SCHEMA_DIR, "tunables/tunable-params-schema.json") + """ + Json config `schema + `__ + for :py:mod:`~mlos_bench.tunables` instances. + """ + TUNABLE_VALUES = path_join(CONFIG_SCHEMA_DIR, "tunables/tunable-values-schema.json") + """ + Json config `schema + `__ + for values of :py:mod:`~mlos_bench.tunables.tunable_groups.TunableGroups` instances. + + These can be used to specify the values of the tunables for a given experiment + using the :py:class:`~mlos_bench.optimizers.one_shot_optimizer.OneShotOptimizer` + for instance. + """ UNIFIED = path_join(CONFIG_SCHEMA_DIR, "mlos-bench-config-schema.json") + """ + Combined global json `schema + `__ + use to validate any ``mlos_bench`` config file (e.g., ``*.mlos.jsonc`` files). + + See Also + -------- + + """ @property def schema(self) -> dict: @@ -141,10 +240,12 @@ def validate(self, config: dict) -> None: Raises ------ jsonschema.exceptions.ValidationError + On validation failure. jsonschema.exceptions.SchemaError + On schema loading error. """ if _SKIP_VALIDATION: - _LOG.warning("%s is set - skip schema validation", _VALIDATION_ENV_FLAG) + _LOG.warning("%s is set - skip schema validation", VALIDATION_ENV_FLAG) else: jsonschema.Draft202012Validator( schema=self.schema, diff --git a/mlos_bench/mlos_bench/dict_templater.py b/mlos_bench/mlos_bench/dict_templater.py index fcd75cf1b92..dd1d1f78afd 100644 --- a/mlos_bench/mlos_bench/dict_templater.py +++ b/mlos_bench/mlos_bench/dict_templater.py @@ -2,7 +2,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Simple class to help with nested dictionary $var templating.""" +"""Simple class to help with nested dictionary ``$var`` templating in configuration file +expansions. +""" from copy import deepcopy from string import Template @@ -12,7 +14,7 @@ class DictTemplater: # pylint: disable=too-few-public-methods - """Simple class to help with nested dictionary $var templating.""" + """Simple class to help with nested dictionary ``$var`` templating.""" def __init__(self, source_dict: Dict[str, Any]): """ @@ -51,7 +53,8 @@ def expand_vars( Raises ------ - ValueError on unsupported nested types. + ValueError + On unsupported nested types. """ self._dict = deepcopy(self._template_dict) self._dict = self._expand_vars(self._dict, extra_source_dict, use_os_env) @@ -64,7 +67,7 @@ def _expand_vars( extra_source_dict: Optional[Dict[str, Any]], use_os_env: bool, ) -> Any: - """Recursively expand $var strings in the currently operating dictionary.""" + """Recursively expand ``$var`` strings in the currently operating dictionary.""" if isinstance(value, str): # First try to expand all $vars internally. value = Template(value).safe_substitute(self._dict) diff --git a/mlos_bench/mlos_bench/environments/__init__.py b/mlos_bench/mlos_bench/environments/__init__.py index ff649af50ef..2ddb7c60b7c 100644 --- a/mlos_bench/mlos_bench/environments/__init__.py +++ b/mlos_bench/mlos_bench/environments/__init__.py @@ -2,7 +2,118 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Tunable Environments for mlos_bench.""" +""" +Tunable Environments for mlos_bench. + +.. contents:: Table of Contents + :depth: 3 + +Overview +++++++++ + +Environments are classes that represent an execution setting (i.e., environment) for +running a benchmark or tuning process. + +For instance, a :py:class:`~.LocalEnv` represents a local execution environment, a +:py:class:`~.RemoteEnv` represents a remote execution environment, a +:py:class:`~mlos_bench.environments.remote.vm_env.VMEnv` represents a virtual +machine, etc. + +An Environment goes through a series of *phases* (e.g., +:py:meth:`~.Environment.setup`, :py:meth:`~.Environment.run`, +:py:meth:`~.Environment.teardown`, etc.) that can be used to prepare a VM, workload, +etc.; run a benchmark, script, etc.; and clean up afterwards. +Often, what these phases do (e.g., what commands to execute) will depend on the +specific Environment and the configs that Environment was loaded with. +This lets Environments be very flexible in what they can accomplish. + +Environments can be stacked together with the :py:class:`.CompositeEnv` class to +represent complex setups (e.g., an appication running on a remote VM with a +benchmark running from a local machine). + +See below for the set of Environments currently available in this package. + +Note that additional ones can also be created by extending the base +:py:class:`.Environment` class and referencing them in the :py:mod:`json configs +` using the ``class`` key. + +Environment Tunables +++++++++++++++++++++ + +Each environment can use +:py:class:`~mlos_bench.tunables.tunable_groups.TunableGroups` to specify the set of +configuration parameters that can be optimized or searched. +At each iteration of the optimization process, the optimizer will generate a set of +values for the :py:class:`Tunables ` that the +environment can use to configure itself. + +At a python level, this happens by passing a +:py:meth:`~mlos_bench.tunables.tunable_groups.TunableGroups` object to the +``tunable_groups`` parameter of the :py:class:`~.Environment` constructor, but that +is typically handled by the +:py:meth:`~mlos_bench.services.config_persistence.ConfigPersistenceService.load_environment` +method of the +:py:meth:`~mlos_bench.services.config_persistence.ConfigPersistenceService` invoked +by the ``mlos_bench`` command line tool's :py:class:`mlos_bench.launcher.Launcher` +class. + +In the typical json user level configs, this is specified in the +``include_tunables`` section of the Environment config to include the +:py:class:`~mlos_bench.tunables.tunable_groups.TunableGroups` definitions from other +json files when the :py:class:`~mlos_bench.launcher.Launcher` processes the initial +set of config files. + +The ``tunable_params`` setting in the ``config`` section of the Environment config +can also be used to limit *which* of the ``TunableGroups`` should be used for the +Environment. + +Since :py:mod:`json configs ` also support ``$variable`` +substitution in the values using the `globals` mechanism, this setting can used to +dynamically change the set of active TunableGroups for a given Experiment using only +`globals`, allowing for configs to be more modular and composable. + +Environment Services +++++++++++++++++++++ + +Environments can also reference :py:mod:`~mlos_bench.services` that provide the +necessary support to perform the actions that environment needs for each of its +phases depending upon where its being deployed (e.g., local machine, remote machine, +cloud provider VM, etc.) + +Although this can be done in the Environment config directly with the +``include_services`` key, it is often more useful to do it in the global or +:py:mod:`cli config ` to allow for the same Environment to be +used in different settings (e.g., local machine, SSH accessible machine, Azure VM, +etc.) without having to change the Environment config. + +Variable Propogation +++++++++++++++++++++ +TODO: Document how variable propogation works in the script environments using +required_args, const_args, etc. + +Examples +-------- +While this documentation is generated from the source code and is intended to be a +useful reference on the internal details, most users will be more interested in +generating json configs to be used with the ``mlos_bench`` command line tool. + +For a simple working user oriented example please see the `test_local_env_bench.jsonc +`_ +file or other examples in the source tree linked below. + +For more developer oriented examples please see the `mlos_bench/tests/environments +`_ +directory in the source tree. + +Notes +----- +- See `mlos_bench/environments/README.md + `_ + for additional documentation in the source tree. +- See `mlos_bench/config/environments/README.md + `_ + for additional config examples in the source tree. +""" from mlos_bench.environments.base_environment import Environment from mlos_bench.environments.composite_env import CompositeEnv diff --git a/mlos_bench/mlos_bench/environments/base_environment.py b/mlos_bench/mlos_bench/environments/base_environment.py index ba346b67654..8c3ab6c3fa9 100644 --- a/mlos_bench/mlos_bench/environments/base_environment.py +++ b/mlos_bench/mlos_bench/environments/base_environment.py @@ -15,6 +15,7 @@ Dict, Iterable, List, + Literal, Optional, Sequence, Tuple, @@ -23,7 +24,6 @@ ) from pytz import UTC -from typing_extensions import Literal from mlos_bench.config.schemas import ConfigSchema from mlos_bench.dict_templater import DictTemplater @@ -422,7 +422,7 @@ def run(self) -> Tuple[Status, datetime, Optional[Dict[str, TunableValue]]]: Returns ------- - (status, timestamp, output) : (Status, datetime, dict) + (status, timestamp, output) : (Status, datetime.datetime, dict) 3-tuple of (Status, timestamp, output) values, where `output` is a dict with the results or None if the status is not COMPLETED. If run script is a benchmark, then the score is usually expected to @@ -439,7 +439,7 @@ def status(self) -> Tuple[Status, datetime, List[Tuple[datetime, str, Any]]]: Returns ------- - (benchmark_status, timestamp, telemetry) : (Status, datetime, list) + (benchmark_status, timestamp, telemetry) : (Status, datetime.datetime, list) 3-tuple of (benchmark status, timestamp, telemetry) values. `timestamp` is UTC time stamp of the status; it's current time by default. `telemetry` is a list (maybe empty) of (timestamp, metric, value) triplets. diff --git a/mlos_bench/mlos_bench/environments/composite_env.py b/mlos_bench/mlos_bench/environments/composite_env.py index a7edb7bb280..d81bd2f729c 100644 --- a/mlos_bench/mlos_bench/environments/composite_env.py +++ b/mlos_bench/mlos_bench/environments/composite_env.py @@ -7,9 +7,7 @@ import logging from datetime import datetime from types import TracebackType -from typing import Any, Dict, List, Optional, Tuple, Type - -from typing_extensions import Literal +from typing import Any, Dict, List, Literal, Optional, Tuple, Type from mlos_bench.environments.base_environment import Environment from mlos_bench.environments.status import Status @@ -207,7 +205,7 @@ def run(self) -> Tuple[Status, datetime, Optional[Dict[str, TunableValue]]]: Returns ------- - (status, timestamp, output) : (Status, datetime, dict) + (status, timestamp, output) : (Status, datetime.datetime, dict) 3-tuple of (Status, timestamp, output) values, where `output` is a dict with the results or None if the status is not COMPLETED. If run script is a benchmark, then the score is usually expected to @@ -238,7 +236,7 @@ def status(self) -> Tuple[Status, datetime, List[Tuple[datetime, str, Any]]]: Returns ------- - (benchmark_status, timestamp, telemetry) : (Status, datetime, list) + (benchmark_status, timestamp, telemetry) : (Status, datetime.datetime, list) 3-tuple of (benchmark status, timestamp, telemetry) values. `timestamp` is UTC time stamp of the status; it's current time by default. `telemetry` is a list (maybe empty) of (timestamp, metric, value) triplets. diff --git a/mlos_bench/mlos_bench/environments/local/local_env.py b/mlos_bench/mlos_bench/environments/local/local_env.py index 754cdd34065..344dd593b34 100644 --- a/mlos_bench/mlos_bench/environments/local/local_env.py +++ b/mlos_bench/mlos_bench/environments/local/local_env.py @@ -2,7 +2,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Scheduler-side benchmark environment to run scripts locally.""" +""" +Scheduler-side benchmark environment to run scripts locally. + +TODO: Reference the script_env.py file for the base class. +""" import json import logging @@ -11,10 +15,20 @@ from datetime import datetime from tempfile import TemporaryDirectory from types import TracebackType -from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Type, Union +from typing import ( + Any, + Dict, + Iterable, + List, + Literal, + Mapping, + Optional, + Tuple, + Type, + Union, +) import pandas -from typing_extensions import Literal from mlos_bench.environments.base_environment import Environment from mlos_bench.environments.script_env import ScriptEnv @@ -167,7 +181,7 @@ def run(self) -> Tuple[Status, datetime, Optional[Dict[str, TunableValue]]]: Returns ------- - (status, timestamp, output) : (Status, datetime, dict) + (status, timestamp, output) : (Status, datetime.datetime, dict) 3-tuple of (Status, timestamp, output) values, where `output` is a dict with the results or None if the status is not COMPLETED. If run script is a benchmark, then the score is usually expected to diff --git a/mlos_bench/mlos_bench/environments/local/local_fileshare_env.py b/mlos_bench/mlos_bench/environments/local/local_fileshare_env.py index 351ed9c480a..70281b5a036 100644 --- a/mlos_bench/mlos_bench/environments/local/local_fileshare_env.py +++ b/mlos_bench/mlos_bench/environments/local/local_fileshare_env.py @@ -175,7 +175,7 @@ def run(self) -> Tuple[Status, datetime, Optional[Dict[str, TunableValue]]]: Returns ------- - (status, timestamp, output) : (Status, datetime, dict) + (status, timestamp, output) : (Status, datetime.datetime, dict) 3-tuple of (Status, timestamp, output) values, where `output` is a dict with the results or None if the status is not COMPLETED. If run script is a benchmark, then the score is usually expected to diff --git a/mlos_bench/mlos_bench/environments/mock_env.py b/mlos_bench/mlos_bench/environments/mock_env.py index 6d3309f35b5..a4d61fa9e37 100644 --- a/mlos_bench/mlos_bench/environments/mock_env.py +++ b/mlos_bench/mlos_bench/environments/mock_env.py @@ -14,7 +14,8 @@ from mlos_bench.environments.base_environment import Environment from mlos_bench.environments.status import Status from mlos_bench.services.base_service import Service -from mlos_bench.tunables import Tunable, TunableGroups, TunableValue +from mlos_bench.tunables.tunable import Tunable, TunableValue +from mlos_bench.tunables.tunable_groups import TunableGroups _LOG = logging.getLogger(__name__) @@ -87,7 +88,7 @@ def run(self) -> Tuple[Status, datetime, Optional[Dict[str, TunableValue]]]: Returns ------- - (status, timestamp, output) : (Status, datetime, dict) + (status, timestamp, output) : (Status, datetime.datetime, dict) 3-tuple of (Status, timestamp, output) values, where `output` is a dict with the results or None if the status is not COMPLETED. The keys of the `output` dict are the names of the metrics @@ -106,7 +107,7 @@ def status(self) -> Tuple[Status, datetime, List[Tuple[datetime, str, Any]]]: Returns ------- - (benchmark_status, timestamp, telemetry) : (Status, datetime, list) + (benchmark_status, timestamp, telemetry) : (Status, datetime.datetime, list) 3-tuple of (benchmark status, timestamp, telemetry) values. `timestamp` is UTC time stamp of the status; it's current time by default. `telemetry` is a list (maybe empty) of (timestamp, metric, value) triplets. diff --git a/mlos_bench/mlos_bench/environments/remote/remote_env.py b/mlos_bench/mlos_bench/environments/remote/remote_env.py index c3535d1a6a0..0b1ed314663 100644 --- a/mlos_bench/mlos_bench/environments/remote/remote_env.py +++ b/mlos_bench/mlos_bench/environments/remote/remote_env.py @@ -6,6 +6,8 @@ Remotely executed benchmark/script environment. e.g. Application Environment + +TODO: Documentat how variable propogation works in the remote environments. """ import logging @@ -138,7 +140,7 @@ def run(self) -> Tuple[Status, datetime, Optional[Dict[str, TunableValue]]]: Returns ------- - (status, timestamp, output) : (Status, datetime, dict) + (status, timestamp, output) : (Status, datetime.datetime, dict) 3-tuple of (Status, timestamp, output) values, where `output` is a dict with the results or None if the status is not COMPLETED. If run script is a benchmark, then the score is usually expected to @@ -180,7 +182,7 @@ def _remote_exec( Returns ------- - result : (Status, datetime, dict) + result : (Status, datetime.datetime, dict) 3-tuple of Status, timestamp, and dict with the benchmark/script results. Status is one of {PENDING, SUCCEEDED, FAILED, TIMED_OUT} """ diff --git a/mlos_bench/mlos_bench/environments/script_env.py b/mlos_bench/mlos_bench/environments/script_env.py index 7938ab65f80..9dc6d661433 100644 --- a/mlos_bench/mlos_bench/environments/script_env.py +++ b/mlos_bench/mlos_bench/environments/script_env.py @@ -2,7 +2,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Base scriptable benchmark environment.""" +""" +Base scriptable benchmark environment. + +TODO: Document how variable propogation works in the script environments using +shell_env_params, required_args, const_args, etc. +""" import abc import logging @@ -19,7 +24,10 @@ class ScriptEnv(Environment, metaclass=abc.ABCMeta): - """Base Environment that runs scripts for setup/run/teardown.""" + """Base Environment that runs scripts for the different phases (e.g., + :py:meth:`.Environment.setup`, :py:meth:`.Environment.run`, + :py:meth:`.Environment.teardown`, etc.) + """ _RE_INVALID = re.compile(r"[^a-zA-Z0-9_]") @@ -37,7 +45,7 @@ def __init__( # pylint: disable=too-many-arguments Parameters ---------- - name: str + name : str Human-readable name of the environment. config : dict Free-format dictionary that contains the benchmark environment @@ -45,11 +53,13 @@ def __init__( # pylint: disable=too-many-arguments and the `const_args` sections. It must also have at least one of the following parameters: {`setup`, `run`, `teardown`}. Additional parameters: - * `shell_env_params` - an array of parameters to pass to the script - as shell environment variables, and - * `shell_env_params_rename` - a dictionary of {to: from} mappings - of the script parameters. If not specified, replace all - non-alphanumeric characters with underscores. + + - `shell_env_params` - an array of parameters to pass to the script + as shell environment variables, and + - `shell_env_params_rename` - a dictionary of {to: from} mappings + of the script parameters. If not specified, replace all + non-alphanumeric characters with underscores. + If neither `shell_env_params` nor `shell_env_params_rename` are specified, *no* additional shell parameters will be passed to the script. global_config : dict @@ -57,7 +67,7 @@ def __init__( # pylint: disable=too-many-arguments to be mixed in into the "const_args" section of the local config. tunables : TunableGroups A collection of tunable parameters for *all* environments. - service: Service + service : Service An optional service object (e.g., providing methods to deploy or reboot a VM, etc.). """ diff --git a/mlos_bench/mlos_bench/event_loop_context.py b/mlos_bench/mlos_bench/event_loop_context.py index 65285e5d66a..39896030872 100644 --- a/mlos_bench/mlos_bench/event_loop_context.py +++ b/mlos_bench/mlos_bench/event_loop_context.py @@ -19,8 +19,11 @@ from typing_extensions import TypeAlias CoroReturnType = TypeVar("CoroReturnType") # pylint: disable=invalid-name +"""Type variable for the return type of an :external:py:mod:`asyncio` coroutine.""" + if sys.version_info >= (3, 9): FutureReturnType: TypeAlias = Future[CoroReturnType] + """Type variable for the return type of a :py:class:`~concurrent.futures.Future`.""" else: FutureReturnType: TypeAlias = Future @@ -29,15 +32,15 @@ class EventLoopContext: """ - EventLoopContext encapsulates a background thread for asyncio event loop processing - as an aid for context managers. + EventLoopContext encapsulates a background thread for :external:py:mod:`asyncio` + event loop processing as an aid for context managers. There is generally only expected to be one of these, either as a base class instance if it's specific to that functionality or for the full mlos_bench process to support parallel trial runners, for instance. - It's enter() and exit() routines are expected to be called from the caller's context - manager routines (e.g., __enter__ and __exit__). + It's :py:meth:`.enter` and :py:meth:`.exit` routines are expected to be called + from the caller's context manager routines (e.g., __enter__ and __exit__). """ def __init__(self) -> None: @@ -94,7 +97,7 @@ def run_coroutine(self, coro: Coroutine[Any, Any, CoroReturnType]) -> FutureRetu Returns ------- - Future[CoroReturnType] + concurrent.futures.Future[CoroReturnType] A future that will be completed when the coroutine completes. """ assert self._event_loop_thread_refcnt > 0 diff --git a/mlos_bench/mlos_bench/launcher.py b/mlos_bench/mlos_bench/launcher.py index 339a11963de..b970b98456e 100644 --- a/mlos_bench/mlos_bench/launcher.py +++ b/mlos_bench/mlos_bench/launcher.py @@ -6,8 +6,8 @@ A helper class to load the configuration files, parse the command line parameters, and instantiate the main components of mlos_bench system. -It is used in `mlos_bench.run` module to run the benchmark/optimizer from the -command line. +It is used in the :py:mod:`mlos_bench.run` module to run the benchmark/optimizer +from the command line. """ import argparse diff --git a/mlos_bench/mlos_bench/optimizers/__init__.py b/mlos_bench/mlos_bench/optimizers/__init__.py index 106b0fc496b..7f2d6310c9a 100644 --- a/mlos_bench/mlos_bench/optimizers/__init__.py +++ b/mlos_bench/mlos_bench/optimizers/__init__.py @@ -2,7 +2,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Interfaces and wrapper classes for optimizers to be used in Autotune.""" +""" +Interfaces and wrapper classes for optimizers to be used in mlos_bench for autotuning or +benchmarking. + +TODO: Improve documentation here. +""" from mlos_bench.optimizers.base_optimizer import Optimizer from mlos_bench.optimizers.manual_optimizer import ManualOptimizer diff --git a/mlos_bench/mlos_bench/optimizers/base_optimizer.py b/mlos_bench/mlos_bench/optimizers/base_optimizer.py index d9a854b476e..14eb3eba6ce 100644 --- a/mlos_bench/mlos_bench/optimizers/base_optimizer.py +++ b/mlos_bench/mlos_bench/optimizers/base_optimizer.py @@ -9,10 +9,9 @@ import logging from abc import ABCMeta, abstractmethod from types import TracebackType -from typing import Dict, Optional, Sequence, Tuple, Type, Union +from typing import Dict, Literal, Optional, Sequence, Tuple, Type, Union from ConfigSpace import ConfigurationSpace -from typing_extensions import Literal from mlos_bench.config.schemas import ConfigSchema from mlos_bench.environments.status import Status @@ -186,7 +185,7 @@ def config_space(self) -> ConfigurationSpace: Returns ------- - ConfigurationSpace + ConfigSpace.ConfigurationSpace The ConfigSpace representation of the tunable parameters. """ if self._config_space is None: @@ -206,7 +205,7 @@ def name(self) -> str: @property def targets(self) -> Dict[str, Literal["min", "max"]]: - """A dictionary of {target: direction} of optimization targets.""" + """Returns a dictionary of optimization targets and their direction.""" return { opt_target: "min" if opt_dir == 1 else "max" for (opt_target, opt_dir) in self._opt_targets.items() diff --git a/mlos_bench/mlos_bench/optimizers/convert_configspace.py b/mlos_bench/mlos_bench/optimizers/convert_configspace.py index 755918fa99d..3545936623c 100644 --- a/mlos_bench/mlos_bench/optimizers/convert_configspace.py +++ b/mlos_bench/mlos_bench/optimizers/convert_configspace.py @@ -73,7 +73,7 @@ def _tunable_to_configspace( Returns ------- - cs : ConfigurationSpace + cs : ConfigSpace.ConfigurationSpace A ConfigurationSpace object that corresponds to the Tunable. """ # pylint: disable=too-complex @@ -206,7 +206,7 @@ def tunable_groups_to_configspace( Returns ------- - configspace : ConfigurationSpace + configspace : ConfigSpace.ConfigurationSpace A new ConfigurationSpace instance that corresponds to the input TunableGroups. """ space = ConfigurationSpace(seed=seed) @@ -234,7 +234,7 @@ def tunable_values_to_configuration(tunables: TunableGroups) -> Configuration: Returns ------- - Configuration + ConfigSpace.Configuration A ConfigSpace Configuration. """ values: Dict[str, TunableValue] = {} diff --git a/mlos_bench/mlos_bench/optimizers/manual_optimizer.py b/mlos_bench/mlos_bench/optimizers/manual_optimizer.py index d9f48a4e193..e9c3ecde192 100644 --- a/mlos_bench/mlos_bench/optimizers/manual_optimizer.py +++ b/mlos_bench/mlos_bench/optimizers/manual_optimizer.py @@ -2,7 +2,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Optimizer for mlos_bench that proposes an explicit sequence of configurations.""" +""" +Manual config suggestor (Optimizer) for mlos_bench that proposes an explicit sequence of +configurations. + +This is useful for testing and validation, as it allows you to run a sequence of +configurations in a cyclic fashion. + +TODO: Add an example configuration. +""" import logging from typing import Dict, List, Optional diff --git a/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py b/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py index 649b070123b..f9d5685ae8f 100644 --- a/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py +++ b/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py @@ -7,10 +7,9 @@ import logging import os from types import TracebackType -from typing import Dict, Optional, Sequence, Tuple, Type, Union +from typing import Dict, Literal, Optional, Sequence, Tuple, Type, Union import pandas as pd -from typing_extensions import Literal from mlos_bench.environments.status import Status from mlos_bench.optimizers.base_optimizer import Optimizer diff --git a/mlos_bench/mlos_bench/os_environ.py b/mlos_bench/mlos_bench/os_environ.py index f750f120389..c83ce05b343 100644 --- a/mlos_bench/mlos_bench/os_environ.py +++ b/mlos_bench/mlos_bench/os_environ.py @@ -4,13 +4,16 @@ # """ Simple platform agnostic abstraction for the OS environment variables. Meant as a -replacement for os.environ vs nt.environ. +replacement for :external:py:data:`os.environ` vs ``nt.environ``. Example ------- -from mlos_bench.os_env import environ -environ['FOO'] = 'bar' -environ.get('PWD') +>>> # Import the environ object. +>>> from mlos_bench.os_environ import environ +>>> # Set an environment variable. +>>> environ["FOO"] = "bar" +>>> # Get an environment variable. +>>> pwd = environ.get("PWD") """ import os @@ -33,7 +36,9 @@ import nt # type: ignore[import-not-found] # pylint: disable=import-error # (3.8) environ: EnvironType = nt.environ + """A platform agnostic abstraction for the OS environment variables.""" else: environ: EnvironType = os.environ + """A platform agnostic abstraction for the OS environment variables.""" __all__ = ["environ"] diff --git a/mlos_bench/mlos_bench/run.py b/mlos_bench/mlos_bench/run.py index a554f1a803d..cc3cf60b8f6 100755 --- a/mlos_bench/mlos_bench/run.py +++ b/mlos_bench/mlos_bench/run.py @@ -4,11 +4,16 @@ # Licensed under the MIT License. # """ -OS Autotune main optimization loop. +mlos_bench main optimization loop and benchmark runner CLI. -Note: this script is also available as a CLI tool via pip under the name "mlos_bench". +Note: this script is also available as a CLI tool via ``pip`` under the name ``mlos_bench``. -See `--help` output for details. +See the current ``--help`` `output for details `_. + +See Also +-------- +mlos_bench.config : documentation on the configuration system. +mlos_bench.launcher.Launcher : class is responsible for processing the CLI args. """ import logging diff --git a/mlos_bench/mlos_bench/schedulers/base_scheduler.py b/mlos_bench/mlos_bench/schedulers/base_scheduler.py index f38e51e7133..5d51650a59f 100644 --- a/mlos_bench/mlos_bench/schedulers/base_scheduler.py +++ b/mlos_bench/mlos_bench/schedulers/base_scheduler.py @@ -9,10 +9,9 @@ from abc import ABCMeta, abstractmethod from datetime import datetime from types import TracebackType -from typing import Any, Dict, List, Optional, Tuple, Type +from typing import Any, Dict, List, Literal, Optional, Tuple, Type from pytz import UTC -from typing_extensions import Literal from mlos_bench.config.schemas import ConfigSchema from mlos_bench.environments.base_environment import Environment diff --git a/mlos_bench/mlos_bench/services/__init__.py b/mlos_bench/mlos_bench/services/__init__.py index b768afb09c8..65ffc8e8d80 100644 --- a/mlos_bench/mlos_bench/services/__init__.py +++ b/mlos_bench/mlos_bench/services/__init__.py @@ -2,7 +2,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Services for implementing Environments for mlos_bench.""" +""" +Services for implementing Environments for mlos_bench. + +TODO: Improve documentation here. +""" from mlos_bench.services.base_fileshare import FileShareService from mlos_bench.services.base_service import Service diff --git a/mlos_bench/mlos_bench/services/base_service.py b/mlos_bench/mlos_bench/services/base_service.py index c5d9b78c873..8afb0b55f71 100644 --- a/mlos_bench/mlos_bench/services/base_service.py +++ b/mlos_bench/mlos_bench/services/base_service.py @@ -7,9 +7,7 @@ import json import logging from types import TracebackType -from typing import Any, Callable, Dict, List, Optional, Set, Type, Union - -from typing_extensions import Literal +from typing import Any, Callable, Dict, List, Literal, Optional, Set, Type, Union from mlos_bench.config.schemas import ConfigSchema from mlos_bench.services.types.config_loader_type import SupportsConfigLoading diff --git a/mlos_bench/mlos_bench/services/config_persistence.py b/mlos_bench/mlos_bench/services/config_persistence.py index 72bfad007de..cd0f42bac40 100644 --- a/mlos_bench/mlos_bench/services/config_persistence.py +++ b/mlos_bench/mlos_bench/services/config_persistence.py @@ -25,7 +25,7 @@ import json5 # To read configs with comments and other JSON5 syntax features from jsonschema import SchemaError, ValidationError -from mlos_bench.config.schemas import ConfigSchema +from mlos_bench.config.schemas.config_schemas import ConfigSchema from mlos_bench.environments.base_environment import Environment from mlos_bench.optimizers.base_optimizer import Optimizer from mlos_bench.services.base_service import Service diff --git a/mlos_bench/mlos_bench/services/local/temp_dir_context.py b/mlos_bench/mlos_bench/services/local/temp_dir_context.py index e65a45934b7..bf730ae3452 100644 --- a/mlos_bench/mlos_bench/services/local/temp_dir_context.py +++ b/mlos_bench/mlos_bench/services/local/temp_dir_context.py @@ -77,7 +77,7 @@ def temp_dir_context( Returns ------- - temp_dir_context : TemporaryDirectory + temp_dir_context : tempfile.TemporaryDirectory Temporary directory context to use in the `with` clause. """ temp_dir = path or self._temp_dir diff --git a/mlos_bench/mlos_bench/services/remote/azure/azure_network_services.py b/mlos_bench/mlos_bench/services/remote/azure/azure_network_services.py index 29552de4f04..4f11e89aa2f 100644 --- a/mlos_bench/mlos_bench/services/remote/azure/azure_network_services.py +++ b/mlos_bench/mlos_bench/services/remote/azure/azure_network_services.py @@ -125,7 +125,7 @@ def provision_network(self, params: dict) -> Tuple[Status, dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is the input `params` plus the parameters extracted from the response JSON, or {} if the status is FAILED. Status is one of {PENDING, SUCCEEDED, FAILED} @@ -140,13 +140,13 @@ def deprovision_network(self, params: dict, ignore_errors: bool = True) -> Tuple ---------- params : dict Flat dictionary of (key, value) pairs of tunable parameters. - ignore_errors : boolean + ignore_errors : bool Whether to ignore errors (default) encountered during the operation (e.g., due to dependent resources still in use). Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ diff --git a/mlos_bench/mlos_bench/services/remote/azure/azure_saas.py b/mlos_bench/mlos_bench/services/remote/azure/azure_saas.py index 042e599f0be..fcd99991f87 100644 --- a/mlos_bench/mlos_bench/services/remote/azure/azure_saas.py +++ b/mlos_bench/mlos_bench/services/remote/azure/azure_saas.py @@ -130,7 +130,7 @@ def configure(self, config: Dict[str, Any], params: Dict[str, Any]) -> Tuple[Sta Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -202,7 +202,7 @@ def _config_one( Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -236,7 +236,7 @@ def _config_many(self, config: Dict[str, Any], params: Dict[str, Any]) -> Tuple[ Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -259,7 +259,7 @@ def _config_batch(self, config: Dict[str, Any], params: Dict[str, Any]) -> Tuple Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ diff --git a/mlos_bench/mlos_bench/services/remote/azure/azure_vm_services.py b/mlos_bench/mlos_bench/services/remote/azure/azure_vm_services.py index b62ede5fab5..856f5e99126 100644 --- a/mlos_bench/mlos_bench/services/remote/azure/azure_vm_services.py +++ b/mlos_bench/mlos_bench/services/remote/azure/azure_vm_services.py @@ -280,7 +280,7 @@ def provision_host(self, params: dict) -> Tuple[Status, dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is the input `params` plus the parameters extracted from the response JSON, or {} if the status is FAILED. Status is one of {PENDING, SUCCEEDED, FAILED} @@ -298,7 +298,7 @@ def deprovision_host(self, params: dict) -> Tuple[Status, dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -340,7 +340,7 @@ def deallocate_host(self, params: dict) -> Tuple[Status, dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -375,7 +375,7 @@ def start_host(self, params: dict) -> Tuple[Status, dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -412,7 +412,7 @@ def stop_host(self, params: dict, force: bool = False) -> Tuple[Status, dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -452,7 +452,7 @@ def restart_host(self, params: dict, force: bool = False) -> Tuple[Status, dict] Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ diff --git a/mlos_bench/mlos_bench/services/remote/ssh/ssh_fileshare.py b/mlos_bench/mlos_bench/services/remote/ssh/ssh_fileshare.py index 137ab024d1c..657da670d97 100644 --- a/mlos_bench/mlos_bench/services/remote/ssh/ssh_fileshare.py +++ b/mlos_bench/mlos_bench/services/remote/ssh/ssh_fileshare.py @@ -50,8 +50,8 @@ async def _start_file_copy( Local path to the file/dir. remote_path : str Remote path to the file/dir. - recursive : bool, optional - _description_, by default True + recursive : bool + Whether to copy recursively. By default True. Raises ------ diff --git a/mlos_bench/mlos_bench/services/remote/ssh/ssh_host_service.py b/mlos_bench/mlos_bench/services/remote/ssh/ssh_host_service.py index 36f1f7866ba..83fc9374989 100644 --- a/mlos_bench/mlos_bench/services/remote/ssh/ssh_host_service.py +++ b/mlos_bench/mlos_bench/services/remote/ssh/ssh_host_service.py @@ -208,7 +208,7 @@ def _exec_os_op(self, cmd_opts_list: List[str], params: dict) -> Tuple[Status, d Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -249,7 +249,7 @@ def shutdown(self, params: dict, force: bool = False) -> Tuple[Status, dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -274,7 +274,7 @@ def reboot(self, params: dict, force: bool = False) -> Tuple[Status, dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. Status is one of {PENDING, SUCCEEDED, FAILED} """ diff --git a/mlos_bench/mlos_bench/services/remote/ssh/ssh_service.py b/mlos_bench/mlos_bench/services/remote/ssh/ssh_service.py index 706764a1f1a..7e33d715ec3 100644 --- a/mlos_bench/mlos_bench/services/remote/ssh/ssh_service.py +++ b/mlos_bench/mlos_bench/services/remote/ssh/ssh_service.py @@ -115,7 +115,9 @@ def connection_lost(self, exc: Optional[Exception]) -> None: return super().connection_lost(exc) async def connection(self) -> Optional[SSHClientConnection]: - """Waits for and returns the SSHClientConnection to be established or lost.""" + """Waits for and returns the asyncssh.connection.SSHClientConnection to be + established or lost. + """ _LOG.debug("%s: Waiting for connection to be available.", current_thread().name) await self._conn_event.wait() _LOG.debug("%s: Connection available for %s", current_thread().name, self._connection_id) @@ -176,7 +178,7 @@ async def get_client_connection( Returns ------- - Tuple[SSHClientConnection, SshClient] + Tuple[asyncssh.connection.SSHClientConnection, SshClient] A tuple of (SSHClientConnection, SshClient). """ _LOG.debug("%s: get_client_connection: %s", current_thread().name, connect_params) diff --git a/mlos_bench/mlos_bench/services/types/authenticator_type.py b/mlos_bench/mlos_bench/services/types/authenticator_type.py index b01c30d42de..4d06a7867ff 100644 --- a/mlos_bench/mlos_bench/services/types/authenticator_type.py +++ b/mlos_bench/mlos_bench/services/types/authenticator_type.py @@ -39,6 +39,6 @@ def get_credential(self) -> T_co: Returns ------- - credential : T + credential : T_co Cloud-specific credential object. """ diff --git a/mlos_bench/mlos_bench/services/types/config_loader_type.py b/mlos_bench/mlos_bench/services/types/config_loader_type.py index 33adac67eb4..f0362e8d88d 100644 --- a/mlos_bench/mlos_bench/services/types/config_loader_type.py +++ b/mlos_bench/mlos_bench/services/types/config_loader_type.py @@ -16,7 +16,7 @@ runtime_checkable, ) -from mlos_bench.config.schemas import ConfigSchema +from mlos_bench.config.schemas.config_schemas import ConfigSchema from mlos_bench.tunables.tunable import TunableValue # Avoid's circular import issues. diff --git a/mlos_bench/mlos_bench/services/types/host_ops_type.py b/mlos_bench/mlos_bench/services/types/host_ops_type.py index 166406714da..1ac485530a1 100644 --- a/mlos_bench/mlos_bench/services/types/host_ops_type.py +++ b/mlos_bench/mlos_bench/services/types/host_ops_type.py @@ -25,7 +25,7 @@ def start_host(self, params: dict) -> Tuple["Status", dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -43,7 +43,7 @@ def stop_host(self, params: dict, force: bool = False) -> Tuple["Status", dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -61,7 +61,7 @@ def restart_host(self, params: dict, force: bool = False) -> Tuple["Status", dic Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ diff --git a/mlos_bench/mlos_bench/services/types/host_provisioner_type.py b/mlos_bench/mlos_bench/services/types/host_provisioner_type.py index 1df0716fa13..af5b776a6c5 100644 --- a/mlos_bench/mlos_bench/services/types/host_provisioner_type.py +++ b/mlos_bench/mlos_bench/services/types/host_provisioner_type.py @@ -27,7 +27,7 @@ def provision_host(self, params: dict) -> Tuple["Status", dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -64,7 +64,7 @@ def deprovision_host(self, params: dict) -> Tuple["Status", dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -84,7 +84,7 @@ def deallocate_host(self, params: dict) -> Tuple["Status", dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ diff --git a/mlos_bench/mlos_bench/services/types/local_exec_type.py b/mlos_bench/mlos_bench/services/types/local_exec_type.py index d0c8c357f0d..96d5042d3e9 100644 --- a/mlos_bench/mlos_bench/services/types/local_exec_type.py +++ b/mlos_bench/mlos_bench/services/types/local_exec_type.py @@ -71,6 +71,6 @@ def temp_dir_context( Returns ------- - temp_dir_context : TemporaryDirectory + temp_dir_context : tempfile.TemporaryDirectory Temporary directory context to use in the `with` clause. """ diff --git a/mlos_bench/mlos_bench/services/types/network_provisioner_type.py b/mlos_bench/mlos_bench/services/types/network_provisioner_type.py index 3525fbdee13..542028b7eaf 100644 --- a/mlos_bench/mlos_bench/services/types/network_provisioner_type.py +++ b/mlos_bench/mlos_bench/services/types/network_provisioner_type.py @@ -27,7 +27,7 @@ def provision_network(self, params: dict) -> Tuple["Status", dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -65,13 +65,13 @@ def deprovision_network( ---------- params : dict Flat dictionary of (key, value) pairs of tunable parameters. - ignore_errors : boolean + ignore_errors : bool Whether to ignore errors (default) encountered during the operation (e.g., due to dependent resources still in use). Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ diff --git a/mlos_bench/mlos_bench/services/types/os_ops_type.py b/mlos_bench/mlos_bench/services/types/os_ops_type.py index 8b727f87a6a..dfb5133e035 100644 --- a/mlos_bench/mlos_bench/services/types/os_ops_type.py +++ b/mlos_bench/mlos_bench/services/types/os_ops_type.py @@ -27,7 +27,7 @@ def shutdown(self, params: dict, force: bool = False) -> Tuple["Status", dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -45,7 +45,7 @@ def reboot(self, params: dict, force: bool = False) -> Tuple["Status", dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ diff --git a/mlos_bench/mlos_bench/services/types/remote_config_type.py b/mlos_bench/mlos_bench/services/types/remote_config_type.py index 7e8d0a6e772..5a75dac382d 100644 --- a/mlos_bench/mlos_bench/services/types/remote_config_type.py +++ b/mlos_bench/mlos_bench/services/types/remote_config_type.py @@ -27,7 +27,7 @@ def configure(self, config: Dict[str, Any], params: Dict[str, Any]) -> Tuple["St Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ diff --git a/mlos_bench/mlos_bench/services/types/vm_provisioner_type.py b/mlos_bench/mlos_bench/services/types/vm_provisioner_type.py index 69d24f3fd38..d6c4f1f1c60 100644 --- a/mlos_bench/mlos_bench/services/types/vm_provisioner_type.py +++ b/mlos_bench/mlos_bench/services/types/vm_provisioner_type.py @@ -27,7 +27,7 @@ def vm_provision(self, params: dict) -> Tuple["Status", dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -63,7 +63,7 @@ def vm_start(self, params: dict) -> Tuple["Status", dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -79,7 +79,7 @@ def vm_stop(self, params: dict) -> Tuple["Status", dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -95,7 +95,7 @@ def vm_restart(self, params: dict) -> Tuple["Status", dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ @@ -111,7 +111,7 @@ def vm_deprovision(self, params: dict) -> Tuple["Status", dict]: Returns ------- - result : (Status, dict={}) + result : (Status, dict) A pair of Status and result. The result is always {}. Status is one of {PENDING, SUCCEEDED, FAILED} """ diff --git a/mlos_bench/mlos_bench/storage/__init__.py b/mlos_bench/mlos_bench/storage/__init__.py index 64e70c20f76..840a6d87fce 100644 --- a/mlos_bench/mlos_bench/storage/__init__.py +++ b/mlos_bench/mlos_bench/storage/__init__.py @@ -2,7 +2,58 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Interfaces to the storage backends for OS Autotune.""" +""" +Interfaces to the storage backends for mlos_bench. + +Storage backends (for instance :py:mod:`~mlos_bench.storage.sql`) are used to store +and retrieve the results of experiments and implement a persistent queue for +:py:mod:`~mlos_bench.schedulers`. + +The :py:class:`~mlos_bench.storage.base_storage.Storage` class is the main interface +and provides the ability to + +- Create or reload a new :py:class:`~.Storage.Experiment` with one or more + associated :py:class:`~.Storage.Trial` instances which are used by the + :py:mod:`~mlos_bench.schedulers` during ``mlos_bench`` run time to execute + `Trials`. + + In MLOS terms, an *Experiment* is a group of *Trials* that share the same scripts + and target system. + + A *Trial* is a single run of the target system with a specific *Configuration* + (e.g., set of tunable parameter values). + (Note: other systems may call this a *sample*) + +- Retrieve the :py:class:`~mlos_bench.storage.base_trial_data.TrialData` results + with the :py:attr:`~mlos_bench.storage.base_experiment_data.ExperimentData.trials` + property on a :py:class:`~mlos_bench.storage.base_experiment_data.ExperimentData` + instance via the :py:class:`~.Storage` instance's + :py:attr:`~mlos_bench.storage.base_storage.Storage.experiments` property. + + These can be especially useful with :py:mod:`mlos_viz` for interactive exploration + in a Jupyter Notebook interface, for instance. + +The :py:func:`.from_config` :py:mod:`.storage_factory` function can be used to get a +:py:class:`.Storage` instance from a +:py:attr:`~mlos_bench.config.schemas.config_schemas.ConfigSchema.STORAGE` type json +config. + +Example +------- +TODO: Add example usage. + +See Also +-------- +mlos_bench.storage.base_storage : Base interface for backends. +mlos_bench.storage.base_experiment_data : Base interface for ExperimentData. +mlos_bench.storage.base_trial_data : Base interface for TrialData. + +Notes +----- +- See `sqlite-autotuning notebooks + `_ + for additional examples. +""" from mlos_bench.storage.base_storage import Storage from mlos_bench.storage.storage_factory import from_config diff --git a/mlos_bench/mlos_bench/storage/base_experiment_data.py b/mlos_bench/mlos_bench/storage/base_experiment_data.py index 97f946ef92b..098c1bca26c 100644 --- a/mlos_bench/mlos_bench/storage/base_experiment_data.py +++ b/mlos_bench/mlos_bench/storage/base_experiment_data.py @@ -2,7 +2,29 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Base interface for accessing the stored benchmark experiment data.""" +""" +Base interface for accessing the stored benchmark experiment data. + +An experiment is a collection of trials that are run with a given set of scripts and +target system. + +Each trial is associated with a configuration (e.g., set of tunable parameters), but +multiple trials may use the same config (e.g., for repeat run variability analysis). + +See Also +-------- +ExperimentData.results_df : + Retrieves a pandas DataFrame of the Experiment's trials' results data. +ExperimentData.trials : + Retrieves a dictionary of the Experiment's trials' data. +ExperimentData.tunable_configs : + Retrieves a dictionary of the Experiment's sampled configs data. +ExperimentData.tunable_config_trial_groups : + Retrieves a dictionary of the Experiment's trials' data, grouped by shared + tunable config. +mlos_bench.storage.base_trial_data.TrialData : + Base interface for accessing the stored benchmark trial data. +""" from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING, Dict, Literal, Optional, Tuple @@ -78,7 +100,7 @@ def objectives(self) -> Dict[str, Literal["min", "max"]]: Returns ------- - objectives : Dict[str, objective] + objectives : Dict[str, Literal["min", "max"]] A dictionary of the experiment's objective names (optimization_targets) and their directions (e.g., min or max). """ diff --git a/mlos_bench/mlos_bench/storage/base_storage.py b/mlos_bench/mlos_bench/storage/base_storage.py index 867c4e0bc02..a6b3a1aa90a 100644 --- a/mlos_bench/mlos_bench/storage/base_storage.py +++ b/mlos_bench/mlos_bench/storage/base_storage.py @@ -2,15 +2,31 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Base interface for saving and restoring the benchmark data.""" +""" +Base interface for saving and restoring the benchmark data. + +See Also +-------- +mlos_bench.storage.base_storage.Storage.experiments : + Retrieves a dictionary of the Experiments' data. +mlos_bench.storage.base_experiment_data.ExperimentData.results_df : + Retrieves a pandas DataFrame of the Experiment's trials' results data. +mlos_bench.storage.base_experiment_data.ExperimentData.trials : + Retrieves a dictionary of the Experiment's trials' data. +mlos_bench.storage.base_experiment_data.ExperimentData.tunable_configs : + Retrieves a dictionary of the Experiment's sampled configs data. +mlos_bench.storage.base_experiment_data.ExperimentData.tunable_config_trial_groups : + Retrieves a dictionary of the Experiment's trials' data, grouped by shared + tunable config. +mlos_bench.storage.base_trial_data.TrialData : + Base interface for accessing the stored benchmark trial data. +""" import logging from abc import ABCMeta, abstractmethod from datetime import datetime from types import TracebackType -from typing import Any, Dict, Iterator, List, Optional, Tuple, Type - -from typing_extensions import Literal +from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple, Type from mlos_bench.config.schemas import ConfigSchema from mlos_bench.dict_templater import DictTemplater @@ -258,7 +274,7 @@ def load_telemetry(self, trial_id: int) -> List[Tuple[datetime, str, Any]]: Returns ------- - metrics : List[Tuple[datetime, str, Any]] + metrics : List[Tuple[datetime.datetime, str, Any]] Telemetry data. """ @@ -298,7 +314,7 @@ def pending_trials( Parameters ---------- - timestamp : datetime + timestamp : datetime.datetime The time in UTC to check for scheduled trials. running : bool If True, include the trials that are already running. @@ -323,7 +339,7 @@ def new_trial( ---------- tunables : TunableGroups Tunable parameters to use for the trial. - ts_start : Optional[datetime] + ts_start : Optional[datetime.datetime] Timestamp of the trial start (can be in the future). config : dict Key/value pairs of additional non-tunable parameters of the trial. @@ -360,7 +376,7 @@ def _new_trial( ---------- tunables : TunableGroups Tunable parameters to use for the trial. - ts_start : Optional[datetime] + ts_start : Optional[datetime.datetime] Timestamp of the trial start (can be in the future). config : dict Key/value pairs of additional non-tunable parameters of the trial. @@ -460,7 +476,7 @@ def update( ---------- status : Status Status of the experiment run. - timestamp: datetime + timestamp: datetime.datetime Timestamp of the status and metrics. metrics : Optional[Dict[str, Any]] One or several metrics of the experiment run. @@ -499,9 +515,9 @@ def update_telemetry( ---------- status : Status Current status of the trial. - timestamp: datetime + timestamp: datetime.datetime Timestamp of the status (but not the metrics). - metrics : List[Tuple[datetime, str, Any]] + metrics : List[Tuple[datetime.datetime, str, Any]] Telemetry data. """ _LOG.info("Store telemetry: %s :: %s %d records", self, status, len(metrics)) diff --git a/mlos_bench/mlos_bench/storage/base_trial_data.py b/mlos_bench/mlos_bench/storage/base_trial_data.py index 4782aa92b36..b4b5936a290 100644 --- a/mlos_bench/mlos_bench/storage/base_trial_data.py +++ b/mlos_bench/mlos_bench/storage/base_trial_data.py @@ -2,7 +2,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Base interface for accessing the stored benchmark trial data.""" +""" +Base interface for accessing the stored benchmark trial data. + +A single trial is a single run of an experiment with a given configuration (e.g., set of +tunable parameters). +""" from abc import ABCMeta, abstractmethod from datetime import datetime from typing import TYPE_CHECKING, Any, Dict, Optional diff --git a/mlos_bench/mlos_bench/storage/base_tunable_config_data.py b/mlos_bench/mlos_bench/storage/base_tunable_config_data.py index 62751deb8e9..c925fa7b0ec 100644 --- a/mlos_bench/mlos_bench/storage/base_tunable_config_data.py +++ b/mlos_bench/mlos_bench/storage/base_tunable_config_data.py @@ -2,7 +2,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Base interface for accessing the stored benchmark (tunable) config data.""" +""" +Base interface for accessing the stored benchmark (tunable) config data. + +Note: a configuration in this context is the set of tunable parameter values and can +be used by one or more trials. +""" from abc import ABCMeta, abstractmethod from typing import Any, Dict, Optional diff --git a/mlos_bench/mlos_bench/storage/base_tunable_config_trial_group_data.py b/mlos_bench/mlos_bench/storage/base_tunable_config_trial_group_data.py index c01c7544b39..d17fc74a9db 100644 --- a/mlos_bench/mlos_bench/storage/base_tunable_config_trial_group_data.py +++ b/mlos_bench/mlos_bench/storage/base_tunable_config_trial_group_data.py @@ -2,7 +2,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Base interface for accessing the stored benchmark config trial group data.""" +""" +Base interface for accessing the stored benchmark config trial group data. + +Since a single config may be used by multiple trials, we can group them together for +easier analysis. +""" from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING, Any, Dict, Optional @@ -120,5 +125,5 @@ def results_df(self) -> pandas.DataFrame: See Also -------- - ExperimentData.results + :py:attr:`mlos_bench.storage.base_experiment_data.ExperimentData.results_df` """ diff --git a/mlos_bench/mlos_bench/storage/sql/__init__.py b/mlos_bench/mlos_bench/storage/sql/__init__.py index 9d749ed35d1..84c357e7d62 100644 --- a/mlos_bench/mlos_bench/storage/sql/__init__.py +++ b/mlos_bench/mlos_bench/storage/sql/__init__.py @@ -2,7 +2,30 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Interfaces to the SQL-based storage backends for OS Autotune.""" +"""Interfaces to the SQL-based storage backends for mlos_bench using `SQLAlchemy +`_. + +In general any SQL system supported by SQLAlchemy can be used, but the default is a +local SQLite instance. + +Although the schema is defined (and printable) by the +:py:mod:`mlos_bench.storage.sql.schema` module so direct queries are possible, users +are expected to interact with the data using the +:py:class:`~mlos_bench.storage.sql.experiment_data.ExperimentSqlData` and +:py:class:`~mlos_bench.storage.sql.trial_data.TrialSqlData` interfaces, which can be +obtained from the initial :py:class:`.SqlStorage` instance obtained by +:py:func:`mlos_bench.storage.storage_factory.from_config`. + +Examples +-------- +TODO: Add example usage. + +Notes +----- +See the `mlos_bench/config/storage +`_ +tree for some configuration examples. +""" from mlos_bench.storage.sql.storage import SqlStorage __all__ = [ diff --git a/mlos_bench/mlos_bench/storage/sql/common.py b/mlos_bench/mlos_bench/storage/sql/common.py index 918ed54ff2a..ab827f2d997 100644 --- a/mlos_bench/mlos_bench/storage/sql/common.py +++ b/mlos_bench/mlos_bench/storage/sql/common.py @@ -6,7 +6,8 @@ from typing import Dict, Optional import pandas -from sqlalchemy import Engine, Integer, and_, func, select +from sqlalchemy import Integer, and_, func, select +from sqlalchemy.engine import Engine from mlos_bench.environments.status import Status from mlos_bench.storage.base_experiment_data import ExperimentData diff --git a/mlos_bench/mlos_bench/storage/sql/experiment.py b/mlos_bench/mlos_bench/storage/sql/experiment.py index 56a3f260498..e128f64b223 100644 --- a/mlos_bench/mlos_bench/storage/sql/experiment.py +++ b/mlos_bench/mlos_bench/storage/sql/experiment.py @@ -2,7 +2,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Saving and restoring the benchmark data using SQLAlchemy.""" +""":py:class:`.Storage.Experiment` interface implementation for saving and restoring +the benchmark experiment data using `SQLAlchemy `_ backend. +""" import hashlib import logging @@ -10,7 +12,8 @@ from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple from pytz import UTC -from sqlalchemy import Connection, CursorResult, Engine, Table, column, func, select +from sqlalchemy import Connection, CursorResult, Table, column, func, select +from sqlalchemy.engine import Engine from mlos_bench.environments.status import Status from mlos_bench.storage.base_storage import Storage diff --git a/mlos_bench/mlos_bench/storage/sql/experiment_data.py b/mlos_bench/mlos_bench/storage/sql/experiment_data.py index 9103322ae89..f24ed82268b 100644 --- a/mlos_bench/mlos_bench/storage/sql/experiment_data.py +++ b/mlos_bench/mlos_bench/storage/sql/experiment_data.py @@ -2,12 +2,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""An interface to access the experiment benchmark data stored in SQL DB.""" +"""An interface to access the benchmark experiment data stored in SQL DB using the +:py:class:`.ExperimentData` interface. +""" import logging from typing import Dict, Literal, Optional import pandas -from sqlalchemy import Engine, Integer, String, func +from sqlalchemy import Integer, String, func +from sqlalchemy.engine import Engine from mlos_bench.storage.base_experiment_data import ExperimentData from mlos_bench.storage.base_trial_data import TrialData diff --git a/mlos_bench/mlos_bench/storage/sql/schema.py b/mlos_bench/mlos_bench/storage/sql/schema.py index 3900568b75d..e6e36ea2d14 100644 --- a/mlos_bench/mlos_bench/storage/sql/schema.py +++ b/mlos_bench/mlos_bench/storage/sql/schema.py @@ -2,7 +2,17 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""DB schema definition.""" +""" +DB schema definition for the :py:class:`~mlos_bench.storage.sql.storage.SqlStorage` +backend. + +Notes +----- +The SQL statements are generated by SQLAlchemy, but can be obtained using +``repr`` or ``str`` (e.g., via ``print()``) on this object. +The ``mlos_bench`` CLI will do this automatically if the logging level is set to +``DEBUG``. +""" import logging from typing import Any, List @@ -11,7 +21,6 @@ Column, DateTime, Dialect, - Engine, Float, ForeignKeyConstraint, Integer, @@ -23,6 +32,7 @@ UniqueConstraint, create_mock_engine, ) +from sqlalchemy.engine import Engine _LOG = logging.getLogger(__name__) diff --git a/mlos_bench/mlos_bench/storage/sql/storage.py b/mlos_bench/mlos_bench/storage/sql/storage.py index 3a272ff19cb..495b38e6546 100644 --- a/mlos_bench/mlos_bench/storage/sql/storage.py +++ b/mlos_bench/mlos_bench/storage/sql/storage.py @@ -21,7 +21,9 @@ class SqlStorage(Storage): - """An implementation of the Storage interface using SQLAlchemy backend.""" + """An implementation of the :py:class:`~.Storage` interface using SQLAlchemy + backend. + """ def __init__( self, diff --git a/mlos_bench/mlos_bench/storage/sql/trial.py b/mlos_bench/mlos_bench/storage/sql/trial.py index 5942912efd2..0951e702647 100644 --- a/mlos_bench/mlos_bench/storage/sql/trial.py +++ b/mlos_bench/mlos_bench/storage/sql/trial.py @@ -2,13 +2,16 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Saving and updating benchmark data using SQLAlchemy backend.""" +""":py:class:`.Storage.Trial` interface implementation for saving and restoring +the benchmark trial data using `SQLAlchemy `_ backend. +""" + import logging from datetime import datetime from typing import Any, Dict, List, Literal, Optional, Tuple -from sqlalchemy import Connection, Engine +from sqlalchemy.engine import Connection, Engine from sqlalchemy.exc import IntegrityError from mlos_bench.environments.status import Status diff --git a/mlos_bench/mlos_bench/storage/sql/trial_data.py b/mlos_bench/mlos_bench/storage/sql/trial_data.py index ac57b7b5c03..60027f24194 100644 --- a/mlos_bench/mlos_bench/storage/sql/trial_data.py +++ b/mlos_bench/mlos_bench/storage/sql/trial_data.py @@ -2,12 +2,14 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""An interface to access the benchmark trial data stored in SQL DB.""" +"""An interface to access the benchmark trial data stored in SQL DB using the +:py:class:`.TrialData` interface. +""" from datetime import datetime from typing import TYPE_CHECKING, Optional import pandas -from sqlalchemy import Engine +from sqlalchemy.engine import Engine from mlos_bench.environments.status import Status from mlos_bench.storage.base_trial_data import TrialData diff --git a/mlos_bench/mlos_bench/storage/sql/tunable_config_data.py b/mlos_bench/mlos_bench/storage/sql/tunable_config_data.py index 40225039be5..97fb5d3d0b6 100644 --- a/mlos_bench/mlos_bench/storage/sql/tunable_config_data.py +++ b/mlos_bench/mlos_bench/storage/sql/tunable_config_data.py @@ -2,10 +2,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""An interface to access the tunable config data stored in SQL DB.""" +"""An interface to access the tunable config data stored in a SQL DB using the +:py:class:`.TunableConfigData` interface. +""" import pandas -from sqlalchemy import Engine +from sqlalchemy.engine import Engine from mlos_bench.storage.base_tunable_config_data import TunableConfigData from mlos_bench.storage.sql.schema import DbSchema diff --git a/mlos_bench/mlos_bench/storage/sql/tunable_config_trial_group_data.py b/mlos_bench/mlos_bench/storage/sql/tunable_config_trial_group_data.py index 0e8c022e7f0..6fac71be15f 100644 --- a/mlos_bench/mlos_bench/storage/sql/tunable_config_trial_group_data.py +++ b/mlos_bench/mlos_bench/storage/sql/tunable_config_trial_group_data.py @@ -2,12 +2,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""An interface to access the tunable config trial group data stored in SQL DB.""" +"""An interface to access the tunable config trial group data stored in a SQL DB using +the :py:class:`.TunableConfigTrialGroupData` interface. +""" from typing import TYPE_CHECKING, Dict, Optional import pandas -from sqlalchemy import Engine, Integer, func +from sqlalchemy import Integer, func +from sqlalchemy.engine import Engine from mlos_bench.storage.base_tunable_config_data import TunableConfigData from mlos_bench.storage.base_tunable_config_trial_group_data import ( diff --git a/mlos_bench/mlos_bench/storage/storage_factory.py b/mlos_bench/mlos_bench/storage/storage_factory.py index ea0201717d4..8980323a781 100644 --- a/mlos_bench/mlos_bench/storage/storage_factory.py +++ b/mlos_bench/mlos_bench/storage/storage_factory.py @@ -2,7 +2,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Factory method to create a new Storage instance from configs.""" +""" +Factory method to create a new :py:class:`.Storage` instance from a +:py:attr:`~mlos_bench.config.schemas.config_schemas.ConfigSchema.STORAGE` type json +config. + +See Also +-------- +mlos_bench.storage : For example usage. +""" from typing import Any, Dict, List, Optional diff --git a/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/partial/grid_search_opt_minimal.jsonc b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/partial/grid_search_opt_minimal.jsonc new file mode 100644 index 00000000000..297e3a62c8e --- /dev/null +++ b/mlos_bench/mlos_bench/tests/config/schemas/optimizers/test-cases/good/partial/grid_search_opt_minimal.jsonc @@ -0,0 +1,4 @@ +{ + "class": "mlos_bench.optimizers.grid_search_optimizer.GridSearchOptimizer" + // no config required +} diff --git a/mlos_bench/mlos_bench/tests/event_loop_context_test.py b/mlos_bench/mlos_bench/tests/event_loop_context_test.py index eb92c4c1326..09f94eeb91d 100644 --- a/mlos_bench/mlos_bench/tests/event_loop_context_test.py +++ b/mlos_bench/mlos_bench/tests/event_loop_context_test.py @@ -10,10 +10,9 @@ from asyncio import AbstractEventLoop from threading import Thread from types import TracebackType -from typing import Optional, Type +from typing import Literal, Optional, Type import pytest -from typing_extensions import Literal from mlos_bench.event_loop_context import EventLoopContext diff --git a/mlos_bench/mlos_bench/tunables/tunable.py b/mlos_bench/mlos_bench/tunables/tunable.py index 4d2781ad11b..120be58238d 100644 --- a/mlos_bench/mlos_bench/tunables/tunable.py +++ b/mlos_bench/mlos_bench/tunables/tunable.py @@ -406,7 +406,7 @@ def in_range(self, value: Union[int, float, str, None]) -> bool: @property def category(self) -> Optional[str]: - """Get the current value of the tunable as a number.""" + """Get the current value of the tunable as a string.""" if self.is_categorical: return nullable(str, self._current_value) else: @@ -556,7 +556,7 @@ def range(self) -> Union[Tuple[int, int], Tuple[float, float]]: Returns ------- - range : (number, number) + range : Union[Tuple[int, int], Tuple[float, float]] A 2-tuple of numbers that represents the range of the tunable. Numbers can be int or float, depending on the type of the tunable. """ diff --git a/mlos_bench/mlos_bench/tunables/tunable_groups.py b/mlos_bench/mlos_bench/tunables/tunable_groups.py index 45d8a02f9c9..99772acfd05 100644 --- a/mlos_bench/mlos_bench/tunables/tunable_groups.py +++ b/mlos_bench/mlos_bench/tunables/tunable_groups.py @@ -178,7 +178,7 @@ def __iter__(self) -> Generator[Tuple[Tunable, CovariantTunableGroup], None, Non Returns ------- - [(tunable, group), ...] : iter(Tunable, CovariantTunableGroup) + [(tunable, group), ...] : Generator[Tuple[Tunable, CovariantTunableGroup], None, None] An iterator over all tunables in all groups. Each element is a 2-tuple of an instance of the Tunable parameter and covariant group it belongs to. """ diff --git a/mlos_bench/mlos_bench/util.py b/mlos_bench/mlos_bench/util.py index 945359bcddd..e7cd5a89862 100644 --- a/mlos_bench/mlos_bench/util.py +++ b/mlos_bench/mlos_bench/util.py @@ -39,9 +39,17 @@ from mlos_bench.services.base_service import Service from mlos_bench.storage.base_storage import Storage -# BaseTypeVar is a generic with a constraint of the three base classes. BaseTypeVar = TypeVar("BaseTypeVar", "Environment", "Optimizer", "Scheduler", "Service", "Storage") +"""BaseTypeVar is a generic with a constraint of the main base classes (e.g., +:py:class:`~mlos_bench.environments.base_environment.Environment`, +:py:class:`~mlos_bench.optimizers.base_optimizer.Optimizer`, +:py:class:`~mlos_bench.schedulers.base_scheduler.Scheduler`, +:py:class:`~mlos_bench.services.base_service.Service`, +:py:class:`~mlos_bench.storage.base_storage.Storage`, etc.). +""" + BaseTypes = Union["Environment", "Optimizer", "Scheduler", "Service", "Storage"] +"""Similar to :py:data:`.BaseTypeVar`, BaseTypes is a Union of the main base classes.""" # Adjusted from https://github.com/python/cpython/blob/v3.11.10/Lib/distutils/util.py#L308 @@ -50,8 +58,16 @@ def strtobool(val: str) -> bool: """ Convert a string representation of truth to true (1) or false (0). - True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values are 'n', 'no', - 'f', 'false', 'off', and '0'. Raises ValueError if 'val' is anything else. + Parameters + ---------- + val : str + True values are 'y', 'yes', 't', 'true', 'on', and '1'; + False values are 'n', 'no', 'f', 'false', 'off', and '0'. + + Raises + ------ + ValueError + If 'val' is anything else. """ val = val.lower() if val in {"y", "yes", "t", "true", "on", "1"}: @@ -64,7 +80,7 @@ def strtobool(val: str) -> bool: def preprocess_dynamic_configs(*, dest: dict, source: Optional[dict] = None) -> dict: """ - Replaces all $name values in the destination config with the corresponding value + Replaces all ``$name`` values in the destination config with the corresponding value from the source config. Parameters @@ -353,7 +369,7 @@ def utcify_timestamp(timestamp: datetime, *, origin: Literal["utc", "local"]) -> Parameters ---------- - timestamp : datetime + timestamp : datetime.datetime A timestamp to convert to UTC. Note: The original datetime may or may not have tzinfo associated with it. @@ -367,7 +383,7 @@ def utcify_timestamp(timestamp: datetime, *, origin: Literal["utc", "local"]) -> Returns ------- - datetime + datetime.datetime A datetime with zoneinfo in UTC. """ if timestamp.tzinfo is not None or origin == "local": diff --git a/mlos_core/mlos_core/__init__.py b/mlos_core/mlos_core/__init__.py index b8a72cef92c..13b1bf2af82 100644 --- a/mlos_core/mlos_core/__init__.py +++ b/mlos_core/mlos_core/__init__.py @@ -2,7 +2,112 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Basic initializer module for the mlos_core package.""" +""" +mlos_core is a wrapper around other OSS tuning libraries to provide a consistent +interface for autotuning experimentation. + +:py:mod:`mlos_core` can be installed from `pypi `_ +with ``pip install mlos-core`` from and provides the main +:py:mod:`Optimizer ` portions of the MLOS project for use with +autotuning purposes. +Although it is generally intended to be used with :py:mod:`mlos_bench` to help +automate the generation of ``(config, score)`` pairs to register with the Optimizer, +it can be used independently as well. + +To do this it provides a small set of wrapper classes around other OSS tuning +libraries in order to provide a consistent interface so that the rest of the code +using it can easily exchange one optimizer for another (or even stack them). + +Specifically: + +- :py:class:`~mlos_core.optimizers.optimizer.BaseOptimizer` is the base class for all Optimizers + + Its core methods are: + + - :py:meth:`~mlos_core.optimizers.optimizer.BaseOptimizer.suggest` which returns a + new configuration to evaluate + - :py:meth:`~mlos_core.optimizers.optimizer.BaseOptimizer.register` which registers + a "score" for an evaluated configuration with the Optimizer + + Each operates on Pandas :py:class:`DataFrames ` as the lingua + franca for data science. + +- :py:meth:`mlos_core.optimizers.OptimizerFactory.create` is a factory function + that creates a new :py:type:`~mlos_core.optimizers.ConcreteOptimizer` instance + + To do this it uses the :py:class:`~mlos_core.optimizers.OptimizerType` enum to + specify which underlying optimizer to use (e.g., + :py:class:`~mlos_core.optimizers.OptimizerType.FLAML` or + :py:class:`~mlos_core.optimizers.OptimizerType.SMAC`). + +Examples +-------- +>>> # Import the necessary classes. +>>> import pandas +>>> from ConfigSpace import ConfigurationSpace, UniformIntegerHyperparameter +>>> from mlos_core.optimizers import OptimizerFactory, OptimizerType +>>> from mlos_core.spaces.adapters import SpaceAdapterFactory, SpaceAdapterType +>>> # Create a simple ConfigurationSpace with a single integer hyperparameter. +>>> cs = ConfigurationSpace(seed=1234) +>>> _ = cs.add(UniformIntegerHyperparameter("x", lower=0, upper=10)) +>>> # Create a new optimizer instance using the SMAC optimizer. +>>> opt_args = {"seed": 1234, "max_trials": 100} +>>> space_adpaters_kwargs = {} # no additional args for this example +>>> opt = OptimizerFactory.create( +... parameter_space=cs, +... optimization_targets=["y"], +... optimizer_type=OptimizerType.SMAC, +... optimizer_kwargs=opt_args, +... space_adapter_type=SpaceAdapterType.IDENTITY, # or LLAMATUNE +... space_adapter_kwargs=space_adpaters_kwargs, +... ) +>>> # Get a new configuration suggestion. +>>> (config_df, _metadata_df) = opt.suggest() +>>> # Examine the suggested configuration. +>>> assert len(config_df) == 1 +>>> config_df.iloc[0] +x 3 +Name: 0, dtype: int64 +>>> # Register the configuration and its corresponding target value +>>> score = 42 # a made up score +>>> scores_df = pandas.DataFrame({"y": [score]}) +>>> opt.register(configs=config_df, scores=scores_df) +>>> # Get a new configuration suggestion. +>>> (config_df, _metadata_df) = opt.suggest() +>>> config_df.iloc[0] +x 10 +Name: 0, dtype: int64 +>>> score = 7 # a better made up score +>>> # Optimizers minimize by convention, so a lower score is better +>>> # You can use a negative score to maximize values instead +>>> # +>>> # Convert it to a DataFrame again +>>> scores_df = pandas.DataFrame({"y": [score]}) +>>> opt.register(configs=config_df, scores=scores_df) +>>> # Get the best observations. +>>> (configs_df, scores_df, _contexts_df) = opt.get_best_observations() +>>> # The default is to only return one +>>> assert len(configs_df) == 1 +>>> assert len(scores_df) == 1 +>>> configs_df.iloc[0] +x 10 +Name: 1, dtype: int64 +>>> scores_df.iloc[0] +y 7 +Name: 1, dtype: int64 + +Notes +----- +See `mlos_core/README.md +`_ +for additional documentation and examples in the source tree. +""" from mlos_core.version import VERSION __version__ = VERSION + + +if __name__ == "__main__": + import doctest + + doctest.testmod() diff --git a/mlos_core/mlos_core/optimizers/__init__.py b/mlos_core/mlos_core/optimizers/__init__.py index e9f402c1878..3b00fa00cee 100644 --- a/mlos_core/mlos_core/optimizers/__init__.py +++ b/mlos_core/mlos_core/optimizers/__init__.py @@ -2,7 +2,31 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Basic initializer module for the mlos_core optimizers.""" +""" +Initializer module for the mlos_core optimizers. + +Optimizers are the main component of the :py:mod:`mlos_core` package. +They act as a wrapper around other OSS tuning libraries to provide a consistent API +interface to allow experimenting with different autotuning algorithms. + +The :class:`~mlos_core.optimizers.optimizer.BaseOptimizer` class is the base class +for all Optimizers and provides the core +:py:meth:`~mlos_core.optimizers.optimizer.BaseOptimizer.suggest` and +:py:meth:`~mlos_core.optimizers.optimizer.BaseOptimizer.register` methods. + +This module also provides a simple :py:class:`~.OptimizerFactory` class to +:py:meth:`~.OptimizerFactory.create` an Optimizer. + +Examples +-------- +TODO: Add example usage here. + +Notes +----- +See `mlos_core/optimizers/README.md +`_ +for additional documentation and examples in the source tree. +""" from enum import Enum from typing import List, Optional, TypeVar @@ -16,6 +40,8 @@ from mlos_core.spaces.adapters import SpaceAdapterFactory, SpaceAdapterType __all__ = [ + "OptimizerType", + "ConcreteOptimizer", "SpaceAdapterType", "OptimizerFactory", "BaseOptimizer", @@ -26,34 +52,50 @@ class OptimizerType(Enum): - """Enumerate supported MlosCore optimizers.""" + """Enumerate supported mlos_core optimizers.""" RANDOM = RandomOptimizer - """An instance of RandomOptimizer class will be used.""" + """An instance of :class:`~mlos_core.optimizers.random_optimizer.RandomOptimizer` + class will be used. + """ FLAML = FlamlOptimizer - """An instance of FlamlOptimizer class will be used.""" + """An instance of :class:`~mlos_core.optimizers.flaml_optimizer.FlamlOptimizer` + class will be used. + """ SMAC = SmacOptimizer - """An instance of SmacOptimizer class will be used.""" + """An instance of + :class:`~mlos_core.optimizers.bayesian_optimizers.smac_optimizer.SmacOptimizer` + class will be used. + """ # To make mypy happy, we need to define a type variable for each optimizer type. # https://github.com/python/mypy/issues/12952 # ConcreteOptimizer = TypeVar('ConcreteOptimizer', *[member.value for member in OptimizerType]) # To address this, we add a test for complete coverage of the enum. + ConcreteOptimizer = TypeVar( "ConcreteOptimizer", RandomOptimizer, FlamlOptimizer, SmacOptimizer, ) +""" +Type variable for concrete optimizer classes. + +(e.g., :class:`~mlos_core.optimizers.bayesian_optimizers.smac_optimizer.SmacOptimizer`, etc.) +""" DEFAULT_OPTIMIZER_TYPE = OptimizerType.FLAML +"""Default optimizer type to use if none is specified.""" class OptimizerFactory: - """Simple factory class for creating BaseOptimizer-derived objects.""" + """Simple factory class for creating + :class:`~mlos_core.optimizers.optimizer.BaseOptimizer`-derived objects. + """ # pylint: disable=too-few-public-methods diff --git a/mlos_core/mlos_core/optimizers/bayesian_optimizers/bayesian_optimizer.py b/mlos_core/mlos_core/optimizers/bayesian_optimizers/bayesian_optimizer.py index a39a5516e83..cfdde1656dd 100644 --- a/mlos_core/mlos_core/optimizers/bayesian_optimizers/bayesian_optimizer.py +++ b/mlos_core/mlos_core/optimizers/bayesian_optimizers/bayesian_optimizer.py @@ -29,11 +29,11 @@ def surrogate_predict( Parameters ---------- - configs : pd.DataFrame + configs : pandas.DataFrame Dataframe of configs / parameters. The columns are parameter names and the rows are the configs. - context : pd.DataFrame + context : pandas.DataFrame Not Yet Implemented. """ pass # pylint: disable=unnecessary-pass # pragma: no cover @@ -51,11 +51,11 @@ def acquisition_function( Parameters ---------- - configs : pd.DataFrame + configs : pandas.DataFrame Dataframe of configs / parameters. The columns are parameter names and the rows are the configs. - context : pd.DataFrame + context : pandas.DataFrame Not Yet Implemented. """ pass # pylint: disable=unnecessary-pass # pragma: no cover diff --git a/mlos_core/mlos_core/optimizers/bayesian_optimizers/smac_optimizer.py b/mlos_core/mlos_core/optimizers/bayesian_optimizers/smac_optimizer.py index fc801d7d05c..b41016b013a 100644 --- a/mlos_core/mlos_core/optimizers/bayesian_optimizers/smac_optimizer.py +++ b/mlos_core/mlos_core/optimizers/bayesian_optimizers/smac_optimizer.py @@ -3,9 +3,12 @@ # Licensed under the MIT License. # """ -Contains the wrapper class for SMAC Bayesian optimizers. +Contains the wrapper class for the :py:class:`.SmacOptimizer`. -See Also: +Notes +----- +See the `SMAC3 Documentation `_ for +more details. """ from logging import warning @@ -84,8 +87,8 @@ def __init__( Number of points evaluated at start to bootstrap the optimizer. Default depends on max_trials and number of parameters and max_ratio. Note: it can sometimes be useful to set this to 1 when pre-warming the - optimizer from historical data. - See Also: mlos_bench.optimizer.bulk_register + optimizer from historical data. See Also: + :py:meth:`mlos_bench.optimizers.base_optimizer.Optimizer.bulk_register` max_ratio : Optional[int] Maximum ratio of max_trials to be random configs to be evaluated @@ -93,10 +96,10 @@ def __init__( Useful if you want to explicitly control the number of random configs evaluated at start. - use_default_config: bool + use_default_config : bool Whether to use the default config for the first trial after random initialization. - n_random_probability: float + n_random_probability : float Probability of choosing to evaluate a random configuration during optimization. Defaults to `0.1`. Setting this to a higher value favors exploration over exploitation. """ @@ -193,6 +196,7 @@ def __init__( if max_ratio is not None: assert isinstance(max_ratio, float) and 0.0 <= max_ratio <= 1.0 initial_design_args["max_ratio"] = max_ratio + self._max_ratio = max_ratio # Use the default InitialDesign from SMAC. # (currently SBOL instead of LatinHypercube due to better uniformity @@ -232,6 +236,18 @@ def __del__(self) -> None: # Best-effort attempt to clean up, in case the user forgets to call .cleanup() self.cleanup() + @property + def max_ratio(self) -> Optional[float]: + """ + Gets the `max_ratio` parameter used in py:meth:`constructor <.__init__>` of this + SmacOptimizer. + + Returns + ------- + float + """ + return self._max_ratio + @property def n_random_init(self) -> int: """ @@ -240,7 +256,10 @@ def n_random_init(self) -> int: Note: This may not be equal to the value passed to the initializer, due to logic present in the SMAC. - See Also: max_ratio + + See Also + -------- + :py:attr:`.max_ratio` Returns ------- diff --git a/mlos_core/mlos_core/optimizers/flaml_optimizer.py b/mlos_core/mlos_core/optimizers/flaml_optimizer.py index e5272f103ec..6c0b3a25400 100644 --- a/mlos_core/mlos_core/optimizers/flaml_optimizer.py +++ b/mlos_core/mlos_core/optimizers/flaml_optimizer.py @@ -2,7 +2,14 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Contains the FlamlOptimizer class.""" +""" +Contains the :py:class:`.FlamlOptimizer` class. + +Notes +----- +See the `Flaml Documentation `_ for more +details. +""" from typing import Dict, List, NamedTuple, Optional, Tuple, Union from warnings import warn diff --git a/mlos_core/mlos_core/optimizers/optimizer.py b/mlos_core/mlos_core/optimizers/optimizer.py index d7da71ae864..84f0f8fab61 100644 --- a/mlos_core/mlos_core/optimizers/optimizer.py +++ b/mlos_core/mlos_core/optimizers/optimizer.py @@ -2,7 +2,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Contains the BaseOptimizer abstract class.""" +"""Contains the :py:class:`.BaseOptimizer` abstract class.""" import collections from abc import ABCMeta, abstractmethod @@ -18,7 +18,10 @@ class BaseOptimizer(metaclass=ABCMeta): - """Optimizer abstract base class defining the basic interface.""" + """Optimizer abstract base class defining the basic interface: + :py:meth:`~.BaseOptimizer.suggest`, + :py:meth:`~.BaseOptimizer.register`, + """ # pylint: disable=too-many-instance-attributes @@ -39,15 +42,23 @@ def __init__( The parameter space to optimize. optimization_targets : List[str] The names of the optimization targets to minimize. + To maximize a target, use the negative of the target when registering scores. objective_weights : Optional[List[float]] Optional list of weights of optimization targets. space_adapter : BaseSpaceAdapter The space adapter class to employ for parameter space transformations. """ self.parameter_space: ConfigSpace.ConfigurationSpace = parameter_space + """The parameter space to optimize.""" + self.optimizer_parameter_space: ConfigSpace.ConfigurationSpace = ( parameter_space if space_adapter is None else space_adapter.target_parameter_space ) + """ + The parameter space actually used by the optimizer. + + (in case a :py:mod:`SpaceAdapter ` is used) + """ if space_adapter is not None and space_adapter.orig_parameter_space != parameter_space: raise ValueError("Given parameter space differs from the one given to space adapter") @@ -84,16 +95,16 @@ def register( Parameters ---------- - configs : pd.DataFrame + configs : pandas.DataFrame Dataframe of configs / parameters. The columns are parameter names and the rows are the configs. - scores : pd.DataFrame + scores : pandas.DataFrame Scores from running the configs. The index is the same as the index of the configs. - context : pd.DataFrame + context : pandas.DataFrame Not Yet Implemented. - metadata : Optional[pd.DataFrame] + metadata : Optional[pandas.DataFrame] Metadata returned by the backend optimizer's suggest method. """ # Do some input validation. @@ -134,13 +145,13 @@ def _register( Parameters ---------- - configs : pd.DataFrame + configs : pandas.DataFrame Dataframe of configs / parameters. The columns are parameter names and the rows are the configs. - scores : pd.DataFrame + scores : pandas.DataFrame Scores from running the configs. The index is the same as the index of the configs. - context : pd.DataFrame + context : pandas.DataFrame Not Yet Implemented. """ pass # pylint: disable=unnecessary-pass # pragma: no cover @@ -157,7 +168,7 @@ def suggest( Parameters ---------- - context : pd.DataFrame + context : pandas.DataFrame Not Yet Implemented. defaults : bool Whether or not to return the default config instead of an optimizer guided one. @@ -165,10 +176,10 @@ def suggest( Returns ------- - configuration : pd.DataFrame + configuration : pandas.DataFrame Pandas dataframe with a single row. Column names are the parameter names. - metadata : Optional[pd.DataFrame] + metadata : Optional[pandas.DataFrame] The metadata associated with the given configuration used for evaluations. Backend optimizer specific. """ @@ -203,15 +214,15 @@ def _suggest( Parameters ---------- - context : pd.DataFrame + context : pandas.DataFrame Not Yet Implemented. Returns ------- - configuration : pd.DataFrame + configuration : pandas.DataFrame Pandas dataframe with a single row. Column names are the parameter names. - metadata : Optional[pd.DataFrame] + metadata : Optional[pandas.DataFrame] The metadata associated with the given configuration used for evaluations. Backend optimizer specific. """ @@ -232,12 +243,12 @@ def register_pending( Parameters ---------- - configs : pd.DataFrame + configs : pandas.DataFrame Dataframe of configs / parameters. The columns are parameter names and the rows are the configs. - context : pd.DataFrame + context : pandas.DataFrame Not Yet Implemented. - metadata : Optional[pd.DataFrame] + metadata : Optional[pandas.DataFrame] Metadata returned by the backend optimizer's suggest method. """ pass # pylint: disable=unnecessary-pass # pragma: no cover @@ -248,7 +259,7 @@ def get_observations(self) -> Tuple[pd.DataFrame, pd.DataFrame, Optional[pd.Data Returns ------- - observations : Tuple[pd.DataFrame, pd.DataFrame, Optional[pd.DataFrame]] + observations : Tuple[pandas.DataFrame, pandas.DataFrame, Optional[pandas.DataFrame]] A triplet of (config, score, context) DataFrames of observations. """ if len(self._observations) == 0: @@ -281,7 +292,7 @@ def get_best_observations( Returns ------- - observations : Tuple[pd.DataFrame, pd.DataFrame, Optional[pd.DataFrame]] + observations : Tuple[pandas.DataFrame, pandas.DataFrame, Optional[pandas.DataFrame]] A triplet of best (config, score, context) DataFrames of best observations. """ if len(self._observations) == 0: diff --git a/mlos_core/mlos_core/optimizers/random_optimizer.py b/mlos_core/mlos_core/optimizers/random_optimizer.py index 661a48a373c..a086c9d8042 100644 --- a/mlos_core/mlos_core/optimizers/random_optimizer.py +++ b/mlos_core/mlos_core/optimizers/random_optimizer.py @@ -2,7 +2,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Contains the RandomOptimizer class.""" +"""RandomOptimizer class.""" from typing import Optional, Tuple from warnings import warn @@ -14,13 +14,9 @@ class RandomOptimizer(BaseOptimizer): """ - Optimizer class that produces random suggestions. Useful for baseline comparison - against Bayesian optimizers. + Optimizer class that produces random suggestions. - Parameters - ---------- - parameter_space : ConfigSpace.ConfigurationSpace - The parameter space to optimize. + Useful for baseline comparison against Bayesian optimizers. """ def _register( @@ -38,11 +34,11 @@ def _register( Parameters ---------- - configs : pd.DataFrame + configs : pandas.DataFrame Dataframe of configs / parameters. The columns are parameter names and the rows are the configs. - scores : pd.DataFrame + scores : pandas.DataFrame Scores from running the configs. The index is the same as the index of the configs. context : None diff --git a/mlos_core/mlos_core/spaces/__init__.py b/mlos_core/mlos_core/spaces/__init__.py index 8de6887783d..cc81a5dcc49 100644 --- a/mlos_core/mlos_core/spaces/__init__.py +++ b/mlos_core/mlos_core/spaces/__init__.py @@ -2,4 +2,4 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Space adapters and converters init file.""" +"""Space adapters and converters.""" diff --git a/mlos_core/mlos_core/spaces/adapters/__init__.py b/mlos_core/mlos_core/spaces/adapters/__init__.py index 1645ac9cb45..608993af500 100644 --- a/mlos_core/mlos_core/spaces/adapters/__init__.py +++ b/mlos_core/mlos_core/spaces/adapters/__init__.py @@ -2,7 +2,34 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Basic initializer module for the mlos_core space adapters.""" +""" +Basic initializer module for the mlos_core space adapters. + +Space adapters provide a mechanism for automatic transformation of the original +:py:class:`ConfigSpace.ConfigurationSpace` provided to the optimizer into a new +space that is more suitable for the optimizer. + +By default the :py:class:`.IdentityAdapter` is used, which does not perform any +transformation. +But, for instance, the :py:class:`.LlamaTuneAdapter` can be used to automatically +transform the space to a lower dimensional one. + +See the :py:mod:`mlos_bench.optimizers.mlos_core_optimizer` module for more +information on how to do this with :py:mod:`mlos_bench`. + +This module provides a simple :py:class:`.SpaceAdapterFactory` class to +:py:meth:`~.SpaceAdapterFactory.create` space adapters. + +Examples +-------- +TODO: Add example usage here. + +Notes +----- +See `mlos_core/spaces/adapters/README.md +`_ +for additional documentation and examples in the source tree. +""" from enum import Enum from typing import Optional, TypeVar @@ -13,19 +40,22 @@ from mlos_core.spaces.adapters.llamatune import LlamaTuneAdapter __all__ = [ + "ConcreteSpaceAdapter", "IdentityAdapter", "LlamaTuneAdapter", + "SpaceAdapterFactory", + "SpaceAdapterType", ] class SpaceAdapterType(Enum): - """Enumerate supported MlosCore space adapters.""" + """Enumerate supported mlos_core space adapters.""" IDENTITY = IdentityAdapter - """A no-op adapter will be used.""" + """A no-op adapter (:class:`.IdentityAdapter`) will be used.""" LLAMATUNE = LlamaTuneAdapter - """An instance of LlamaTuneAdapter class will be used.""" + """An instance of :class:`.LlamaTuneAdapter` class will be used.""" # To make mypy happy, we need to define a type variable for each optimizer type. @@ -40,10 +70,15 @@ class SpaceAdapterType(Enum): IdentityAdapter, LlamaTuneAdapter, ) +"""Type variable for concrete SpaceAdapter classes (e.g., +:class:`~mlos_core.spaces.adapters.identity_adapter.IdentityAdapter`, etc.) +""" class SpaceAdapterFactory: - """Simple factory class for creating BaseSpaceAdapter-derived objects.""" + """Simple factory class for creating + :class:`~mlos_core.spaces.adapters.adapter.BaseSpaceAdapter`-derived objects. + """ # pylint: disable=too-few-public-methods diff --git a/mlos_core/mlos_core/spaces/adapters/adapter.py b/mlos_core/mlos_core/spaces/adapters/adapter.py index 2d48a14c317..b4b53b73a08 100644 --- a/mlos_core/mlos_core/spaces/adapters/adapter.py +++ b/mlos_core/mlos_core/spaces/adapters/adapter.py @@ -2,7 +2,18 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Contains the BaseSpaceAdapter abstract class.""" +""" +Contains the BaseSpaceAdapter abstract class. + +As mentioned in :py:mod:`mlos_core.spaces.adapters`, the space adapters provide a +mechanism for automatic transformation of the original +:py:class:`ConfigSpace.ConfigurationSpace` provided to the Optimizer into a new +space for the Optimizer to search over. + +It's main APIs are the :py:meth:`~.BaseSpaceAdapter.transform` and +:py:meth:`~.BaseSpaceAdapter.inverse_transform` methods, which are used to translate +configurations from one space to another. +""" from abc import ABCMeta, abstractmethod @@ -47,18 +58,19 @@ def target_parameter_space(self) -> ConfigSpace.ConfigurationSpace: def transform(self, configuration: pd.DataFrame) -> pd.DataFrame: """ Translates a configuration, which belongs to the target parameter space, to the - original parameter space. This method is called by the `suggest` method of the - `BaseOptimizer` class. + original parameter space. This method is called by the + :py:meth:`~mlos_core.optimizers.optimizer.BaseOptimizer.suggest` method of the + :py:class:`~mlos_core.optimizers.optimizer.BaseOptimizer` class. Parameters ---------- - configuration : pd.DataFrame + configuration : pandas.DataFrame Pandas dataframe with a single row. Column names are the parameter names of the target parameter space. Returns ------- - configuration : pd.DataFrame + configuration : pandas.DataFrame Pandas dataframe with a single row, containing the translated configuration. Column names are the parameter names of the original parameter space. """ @@ -69,19 +81,20 @@ def inverse_transform(self, configurations: pd.DataFrame) -> pd.DataFrame: """ Translates a configuration, which belongs to the original parameter space, to the target parameter space. This method is called by the `register` method of - the `BaseOptimizer` class, and performs the inverse operation of - `BaseSpaceAdapter.transform` method. + the :py:class:`~mlos_core.optimizers.optimizer.BaseOptimizer` class, and + performs the inverse operation of :py:meth:`~.BaseSpaceAdapter.transform` + method. Parameters ---------- - configurations : pd.DataFrame + configurations : pandas.DataFrame Dataframe of configurations / parameters, which belong to the original parameter space. The columns are the parameter names the original parameter space and the rows are the configurations. Returns ------- - configurations : pd.DataFrame + configurations : pandas.DataFrame Dataframe of the translated configurations / parameters. The columns are the parameter names of the target parameter space and the rows are the configurations. diff --git a/mlos_core/mlos_core/spaces/adapters/llamatune.py b/mlos_core/mlos_core/spaces/adapters/llamatune.py index 5a39f863a56..625dd886d08 100644 --- a/mlos_core/mlos_core/spaces/adapters/llamatune.py +++ b/mlos_core/mlos_core/spaces/adapters/llamatune.py @@ -2,7 +2,17 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Implementation of LlamaTune space adapter.""" +""" +Implementation of LlamaTune space adapter. + +LlamaTune is a technique that transforms the original parameter space into a +lower-dimensional space to try and improve the sample efficiency of the underlying +optimizer by making use of the inherent parameter sensitivity correlations in most +systems. + +See Also: `LlamaTune: Sample-Efficient DBMS Configuration Tuning +`_. +""" import os from typing import Dict, List, Optional, Union from warnings import warn @@ -53,11 +63,11 @@ def __init__( # pylint: disable=too-many-arguments ---------- orig_parameter_space : ConfigSpace.ConfigurationSpace The original (user-provided) parameter space to optimize. - num_low_dims: int + num_low_dims : int Number of dimensions used in the low-dimensional parameter search space. - special_param_values_dict: Optional[dict] + special_param_values_dict : Optional[dict] Dictionary of special - max_unique_values_per_param: Optional[int]: + max_unique_values_per_param : Optional[int] Number of unique values per parameter. Used to discretize the parameter space. If `None` space discretization is disabled. """ diff --git a/mlos_core/mlos_core/spaces/converters/__init__.py b/mlos_core/mlos_core/spaces/converters/__init__.py index 2360bda24f8..3abebbfbe54 100644 --- a/mlos_core/mlos_core/spaces/converters/__init__.py +++ b/mlos_core/mlos_core/spaces/converters/__init__.py @@ -2,4 +2,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Space converters init file.""" +""" +Space converters init file. + +Space converters are helper functions that translate a +:py:class:`ConfigSpace.ConfigurationSpace` that :py:mod:`mlos_core` Optimizers take +as input to the underlying Optimizer's parameter description language (in case it +doesn't use :py:class:`ConfigSpace.ConfigurationSpace`). +""" diff --git a/mlos_core/mlos_core/spaces/converters/flaml.py b/mlos_core/mlos_core/spaces/converters/flaml.py index 71370853e4a..d0dc5e9b67b 100644 --- a/mlos_core/mlos_core/spaces/converters/flaml.py +++ b/mlos_core/mlos_core/spaces/converters/flaml.py @@ -2,7 +2,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Contains space converters for FLAML.""" +"""Contains space converters for :py:class:`~mlos_core.optimizers.flaml_optimizer`""" import sys from typing import TYPE_CHECKING, Dict @@ -22,7 +22,10 @@ FlamlDomain: TypeAlias = flaml.tune.sample.Domain +"""Flaml domain type alias.""" + FlamlSpace: TypeAlias = Dict[str, flaml.tune.sample.Domain] +"""Flaml space type alias - a `Dict[str, FlamlDomain]`""" def configspace_to_flaml_space( diff --git a/mlos_core/mlos_core/spaces/converters/util.py b/mlos_core/mlos_core/spaces/converters/util.py index 4393890595e..de0edb7cd1b 100644 --- a/mlos_core/mlos_core/spaces/converters/util.py +++ b/mlos_core/mlos_core/spaces/converters/util.py @@ -16,16 +16,19 @@ def monkey_patch_hp_quantization(hp: Hyperparameter) -> Hyperparameter: Monkey-patch quantization into the Hyperparameter. Temporary workaround to dropped quantization support in ConfigSpace 1.0 - See Also: + + Notes + ----- + See . Parameters ---------- - hp : Hyperparameter + hp : ConfigSpace.hyperparameters.Hyperparameter ConfigSpace hyperparameter to patch. Returns ------- - hp : Hyperparameter + hp : ConfigSpace.hyperparameters.Hyperparameter Patched hyperparameter. """ if not isinstance(hp, NumericalHyperparameter): @@ -72,12 +75,12 @@ def monkey_patch_cs_quantization(cs: ConfigurationSpace) -> ConfigurationSpace: Parameters ---------- - cs : ConfigurationSpace + cs : ConfigSpace.ConfigurationSpace ConfigSpace to patch. Returns ------- - cs : ConfigurationSpace + cs : ConfigSpace.ConfigurationSpace Patched ConfigSpace. """ for hp in cs.values(): diff --git a/mlos_core/mlos_core/tests/spaces/spaces_test.py b/mlos_core/mlos_core/tests/spaces/spaces_test.py index b98d40d6270..a54359be906 100644 --- a/mlos_core/mlos_core/tests/spaces/spaces_test.py +++ b/mlos_core/mlos_core/tests/spaces/spaces_test.py @@ -85,7 +85,7 @@ def sample(self, config_space: OptimizerSpace, n_samples: int = 1) -> npt.NDArra ---------- config_space : CS.ConfigurationSpace Configuration space to sample from. - n_samples : int, optional + n_samples : int Number of samples to use, by default 1. """ diff --git a/mlos_core/mlos_core/util.py b/mlos_core/mlos_core/util.py index 027bfd0e35b..2e1c382a31a 100644 --- a/mlos_core/mlos_core/util.py +++ b/mlos_core/mlos_core/util.py @@ -21,7 +21,7 @@ def config_to_dataframe(config: Configuration) -> pd.DataFrame: Returns ------- - pd.DataFrame + pandas.DataFrame A DataFrame with a single row, containing the config's parameters. """ return pd.DataFrame([dict(config)]) @@ -56,14 +56,14 @@ def normalize_config( Parameters ---------- - config_space : ConfigurationSpace + config_space : ConfigSpace.ConfigurationSpace The parameter space to use. config : dict The configuration to convert. Returns ------- - cs_config: Configuration + cs_config: ConfigSpace.Configuration A valid ConfigSpace configuration with inactive parameters removed. """ cs_config = Configuration(config_space, values=config, allow_inactive_with_values=True) diff --git a/mlos_viz/README.md b/mlos_viz/README.md index e4c5c907426..dd744442c28 100644 --- a/mlos_viz/README.md +++ b/mlos_viz/README.md @@ -1,4 +1,4 @@ -# mlos_viz +# mlos-viz The [`mlos_viz`](./) module is an aid to visualizing experiment benchmarking and optimization results generated and stored by [`mlos_bench`](../mlos_bench/). diff --git a/mlos_viz/mlos_viz/__init__.py b/mlos_viz/mlos_viz/__init__.py index 1dfc795d437..8450e32b12b 100644 --- a/mlos_viz/mlos_viz/__init__.py +++ b/mlos_viz/mlos_viz/__init__.py @@ -2,8 +2,14 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""mlos_viz is a framework to help visualizing, explain, and gain insights from results -from the mlos_bench framework for benchmarking and optimization automation. +""" +mlos_viz is a framework to help visualizing, explain, and gain insights from results +from the :py:mod:`mlos_bench` framework for benchmarking and optimization automation. + +Its main entrypoint is the :py:func:`plot` function, which can be used to +automatically visualize :py:class:`~.ExperimentData` from :py:mod:`mlos_bench` using +other libraries for automatic data correlation and visualization like +:external:py:func:`dabl `. """ from enum import Enum @@ -63,12 +69,12 @@ def plot( ---------- exp_data: ExperimentData The experiment data to plot. - results_df : Optional["pandas.DataFrame"] - Optional results_df to plot. - If not provided, defaults to exp_data.results_df property. + results_df : Optional[pandas.DataFrame] + Optional `results_df` to plot. + If not provided, defaults to :py:attr:`.ExperimentData.results_df` property. objectives : Optional[Dict[str, Literal["min", "max"]]] Optional objectives to plot. - If not provided, defaults to exp_data.objectives property. + If not provided, defaults to :py:attr:`.ExperimentData.objectives` property. plotter_method: MlosVizMethod The method to use for visualizing the experiment results. filter_warnings: bool diff --git a/mlos_viz/mlos_viz/base.py b/mlos_viz/mlos_viz/base.py index 0c6d58cd7f8..e7a52f1bd8c 100644 --- a/mlos_viz/mlos_viz/base.py +++ b/mlos_viz/mlos_viz/base.py @@ -222,13 +222,14 @@ def limit_top_n_configs( exp_data : Optional[ExperimentData] The ExperimentData (e.g., obtained from the storage layer) to operate on. results_df : Optional[pandas.DataFrame] - The results dataframe to augment, by default None to use the results_df property. - objectives : Iterable[str], optional + The results dataframe to augment, by default None to use + :py:attr:`.ExperimentData.results_df` property. + objectives : Iterable[str] Which result column(s) to use for sorting the configs, and in which direction ("min" or "max"). - By default None to automatically select the experiment objectives. - top_n_configs : int, optional - How many configs to return, including the default, by default 20. + By default None to automatically select the :py:attr:`.ExperimentData.objectives`. + top_n_configs : int + How many configs to return, including the default, by default 10. method: Literal["mean", "median", "p50", "p75", "p90", "p95", "p99"] = "mean", Which statistical method to use when sorting the config groups before determining the cutoff, by default "mean". @@ -348,12 +349,12 @@ def plot_optimizer_trends( ---------- exp_data : ExperimentData The ExperimentData (e.g., obtained from the storage layer) to plot. - results_df : Optional["pandas.DataFrame"] + results_df : Optional[pandas.DataFrame] Optional results_df to plot. - If not provided, defaults to exp_data.results_df property. + If not provided, defaults to :py:attr:`.ExperimentData.results_df` property. objectives : Optional[Dict[str, Literal["min", "max"]]] Optional objectives to plot. - If not provided, defaults to exp_data.objectives property. + If not provided, defaults to :py:attr:`.ExperimentData.objectives` property. """ (results_df, obj_cols) = expand_results_data_args(exp_data, results_df, objectives) (results_df, groupby_columns, groupby_column) = _add_groupby_desc_column(results_df) @@ -430,7 +431,8 @@ def plot_top_n_configs( ) -> None: # pylint: disable=too-many-locals """ - Plots the top-N configs along with the default config for the given ExperimentData. + Plots the top-N configs along with the default config for the given + :py:class:`.ExperimentData`. Intended to be used from a Jupyter notebook. @@ -438,16 +440,17 @@ def plot_top_n_configs( ---------- exp_data: ExperimentData The experiment data to plot. - results_df : Optional["pandas.DataFrame"] + results_df : Optional[pandas.DataFrame] Optional results_df to plot. - If not provided, defaults to exp_data.results_df property. + If not provided, defaults to :py:attr:`.ExperimentData.results_df` property. objectives : Optional[Dict[str, Literal["min", "max"]]] Optional objectives to plot. - If not provided, defaults to exp_data.objectives property. + If not provided, defaults to :py:attr:`.ExperimentData.objectives` property. with_scatter_plot : bool Whether to also add scatter plot to the output figure. kwargs : dict - Remaining keyword arguments are passed along to the limit_top_n_configs function. + Remaining keyword arguments are passed along to the + :py:func:`limit_top_n_configs` function. """ (results_df, _obj_cols) = expand_results_data_args(exp_data, results_df, objectives) top_n_config_args = _get_kwarg_defaults(limit_top_n_configs, **kwargs) diff --git a/mlos_viz/mlos_viz/dabl.py b/mlos_viz/mlos_viz/dabl.py index 3f8ac640ad9..918390fdbca 100644 --- a/mlos_viz/mlos_viz/dabl.py +++ b/mlos_viz/mlos_viz/dabl.py @@ -2,7 +2,14 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # -"""Small wrapper functions for dabl plotting functions via mlos_bench data.""" +""" +Small wrapper functions for plotting :py:mod:`mlos_bench` data via +:external:py:func:`dabl.plot`. + +Notes +----- +See `dabl `_ for more information on the dabl library. +""" import warnings from typing import Dict, Literal, Optional @@ -20,13 +27,14 @@ def plot( objectives: Optional[Dict[str, Literal["min", "max"]]] = None, ) -> None: """ - Plots the Experiment results data using dabl. + Plots the :py:class:`~mlos_bench.storage.base_storage.Storage.Experiment` results + data using :external:py:func:`dabl.plot`. Parameters ---------- exp_data : ExperimentData The ExperimentData (e.g., obtained from the storage layer) to plot. - results_df : Optional["pandas.DataFrame"] + results_df : Optional[pandas.DataFrame] Optional results_df to plot. If not provided, defaults to exp_data.results_df property. objectives : Optional[Dict[str, Literal["min", "max"]]] diff --git a/mlos_viz/mlos_viz/util.py b/mlos_viz/mlos_viz/util.py index cefc3080d9c..2e537021125 100644 --- a/mlos_viz/mlos_viz/util.py +++ b/mlos_viz/mlos_viz/util.py @@ -22,14 +22,14 @@ def expand_results_data_args( Parameters ---------- - exp_data : Optional[ExperimentData], optional + exp_data : Optional[ExperimentData] ExperimentData to operate on. - results_df : Optional[pandas.DataFrame], optional + results_df : Optional[pandas.DataFrame] Optional results_df argument. - Defaults to exp_data.results_df property. - objectives : Optional[Dict[str, Literal["min", "max"]]], optional + If not provided, defaults to :py:attr:`.ExperimentData.results_df` property. + objectives : Optional[Dict[str, Literal["min", "max"]]] Optional objectives set to operate on. - Defaults to exp_data.objectives property. + If not provided, defaults to :py:attr:`.ExperimentData.objectives` property. Returns ------- diff --git a/pyproject.toml b/pyproject.toml index 5464aa60b65..b5014df8a87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,3 +68,18 @@ disable = [ [tool.pylint.string] check-quote-consistency = true check-str-concat-over-line-jumps = true + +# Tell the vscode python extension to ignore some autogenerated files. +[tool.pyright] +exclude = [ + ".git", + ".mypy_cache", + ".pytest_cache", + "**/node_modules", + "**/__pycache__", + "**/*.egg-info", + "doc/source/autoapi", + "doc/build/html", + "doc/build/doctrees", + "htmlcov", +] diff --git a/setup.cfg b/setup.cfg index cd3bdbb9916..f8b0c945f2d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,14 +39,15 @@ addopts = -l --ff --nf -n auto + --doctest-modules # --dist loadgroup # --log-level=DEBUG # Moved these to Makefile (coverage is expensive and we only need it in the pipelines generally). #--cov=mlos_core --cov-report=xml -testpaths = mlos_core mlos_bench +testpaths = mlos_core mlos_bench mlos_viz # Ignore some upstream deprecation warnings. filterwarnings = - ignore:.*(get_hyperparam|get_dictionary).*:DeprecationWarning:smac:0 + ignore:.*(get_hyperparam|get_dictionary|get_parents_of|(list\(.*values\(\)\))).*:DeprecationWarning:smac:0 ignore:.*(Please leave at default or explicitly set .size=None).*:DeprecationWarning:smac:0 ignore:.*(Trying to register a configuration that was not previously suggested).*:UserWarning:.*llamatune.*:0 ignore:.*(DISPLAY environment variable is set).*:UserWarning:.*conftest.*:0 From 78205f459f357bd504f955043acea3dc1fd95609 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Thu, 21 Nov 2024 13:41:37 -0800 Subject: [PATCH 32/36] Fixup broken markdown links (#883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title Fixup broken markdown links after #869 --- ## Description After #869 was merged the published documentation links changed which meant that future Markdown lint checks failed. This PR fixes that. --- ## Type of Change - 🛠️ Bug fix - 📝 Documentation update --- --- README.md | 4 ++-- doc/source/index.rst | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index dbc48ffb4fc..47b7874408b 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ To do this this repo provides two Python modules, which can be used independentl It is intended to provide a simple, easy to consume (e.g. via `pip`), with low dependencies abstraction to - describe a space of context, parameters, their ranges, constraints, etc. and result objectives - - an "optimizer" service [abstraction](https://microsoft.github.io/MLOS/overview.html#mlos-core-api) (e.g. [`register()`](https://microsoft.github.io/MLOS/generated/mlos_core.optimizers.optimizer.BaseOptimizer.html#mlos_core.optimizers.optimizer.BaseOptimizer.register) and [`suggest()`](https://microsoft.github.io/MLOS/generated/mlos_core.optimizers.optimizer.BaseOptimizer.html#mlos_core.optimizers.optimizer.BaseOptimizer.suggest)) so we can easily swap out different implementations methods of searching (e.g. random, BO, LLM, etc.) - - provide some helpers for [automating optimization experiment](https://microsoft.github.io/MLOS/overview.html#mlos-bench-api) runner loops and data collection + - an "optimizer" service [abstraction](https://microsoft.github.io/MLOS/source_tree_docs/index.html) (e.g. [`register()`](https://microsoft.github.io/MLOS/autoapi/mlos_bench/optimizers/base_optimizer/index.html#mlos_bench.optimizers.base_optimizer.Optimizer.register) and [`suggest()`](https://microsoft.github.io/MLOS/autoapi/mlos_bench/optimizers/base_optimizer/index.html#mlos_bench.optimizers.base_optimizer.Optimizer.suggest)) so we can easily swap out different implementations methods of searching (e.g. random, BO, LLM, etc.) + - provide some helpers for [automating optimization experiment](https://microsoft.github.io/MLOS/source_tree_docs/mlos_bench/index.html) runner loops and data collection For these design requirements we intend to reuse as much from existing OSS libraries as possible and layer policies and optimizations specifically geared towards autotuning systems over top. diff --git a/doc/source/index.rst b/doc/source/index.rst index 68bd53dbef9..cea832a860f 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -6,7 +6,7 @@ MLOS Documentation .. image:: badges/coverage.svg :target: htmlcov/index.html -`MLOS `_ is a project to enable autotuning for systems via `automated benchmarking `_ including managing the storage and `visualization `_ of the results. +`MLOS `_ is a project to enable `autotuning ` with `mlos_core `_ for systems via `automated benchmarking `_ with `mlos_bench `_ including managing the storage and `visualization `_ of the results via `mlos_viz `_. See below for additional documentation sections. @@ -14,7 +14,7 @@ Here is some documentation pulled from the markdown files in the `MLOS source tr .. toctree:: :caption: Source Tree Documentation - :maxdepth: 5 + :maxdepth: 4 source_tree_docs/index source_tree_docs/mlos_core/index @@ -26,7 +26,7 @@ Here is some documentation pulled from the Python docstrings in the `MLOS source .. toctree:: :caption: API Reference :titlesonly: - :maxdepth: 5 + :maxdepth: 4 autoapi/mlos_core/index autoapi/mlos_bench/index From 022cae861c04c0ac6bdf326f3176526824fac32b Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Thu, 21 Nov 2024 17:11:09 -0800 Subject: [PATCH 33/36] Apply some minor linter complaint fixes (#885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title Apply some minor linter complaint fixes. --- ## Description Splitting out some unrelated new linter complaints from #852 --- ## Type of Change - 🛠️ Bug fix --- --- mlos_bench/mlos_bench/optimizers/convert_configspace.py | 3 ++- mlos_bench/mlos_bench/tests/dict_templater_test.py | 6 +++--- .../optimizers/bayesian_optimizers/smac_optimizer.py | 1 + mlos_viz/mlos_viz/base.py | 6 +++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/mlos_bench/mlos_bench/optimizers/convert_configspace.py b/mlos_bench/mlos_bench/optimizers/convert_configspace.py index 3545936623c..6f586454df3 100644 --- a/mlos_bench/mlos_bench/optimizers/convert_configspace.py +++ b/mlos_bench/mlos_bench/optimizers/convert_configspace.py @@ -7,7 +7,8 @@ """ import logging -from typing import Dict, Hashable, List, Optional, Tuple, Union +from collections.abc import Hashable +from typing import Dict, List, Optional, Tuple, Union from ConfigSpace import ( Beta, diff --git a/mlos_bench/mlos_bench/tests/dict_templater_test.py b/mlos_bench/mlos_bench/tests/dict_templater_test.py index 4b64f50fd40..588d1f0ee40 100644 --- a/mlos_bench/mlos_bench/tests/dict_templater_test.py +++ b/mlos_bench/mlos_bench/tests/dict_templater_test.py @@ -85,7 +85,7 @@ def test_os_env_expansion(source_template_dict: Dict[str, Any]) -> None: results = DictTemplater(source_template_dict).expand_vars(use_os_env=True) assert results == { - "extra_str-ref": f"{environ['extra_str']}-ref", + "extra_str-ref": f"{environ['extra_str']}-ref", # pylint: disable=inconsistent-quotes "str": "string", "str_ref": "string-ref", "secondary_expansion": "string-ref", @@ -116,7 +116,7 @@ def test_from_extras_expansion(source_template_dict: Dict[str, Any]) -> None: } results = DictTemplater(source_template_dict).expand_vars(extra_source_dict=extra_source_dict) assert results == { - "extra_str-ref": f"{extra_source_dict['extra_str']}-ref", + "extra_str-ref": f"{extra_source_dict['extra_str']}-ref", # pylint: disable=inconsistent-quotes # noqa: E501 "str": "string", "str_ref": "string-ref", "secondary_expansion": "string-ref", @@ -134,6 +134,6 @@ def test_from_extras_expansion(source_template_dict: Dict[str, Any]) -> None: ], "dict": { "nested-str-ref": "nested-string-ref", - "nested-extra-str-ref": f"nested-{extra_source_dict['extra_str']}-ref", + "nested-extra-str-ref": f"nested-{extra_source_dict['extra_str']}-ref", # pylint: disable=inconsistent-quotes # noqa: E501 }, } diff --git a/mlos_core/mlos_core/optimizers/bayesian_optimizers/smac_optimizer.py b/mlos_core/mlos_core/optimizers/bayesian_optimizers/smac_optimizer.py index b41016b013a..05f0b523642 100644 --- a/mlos_core/mlos_core/optimizers/bayesian_optimizers/smac_optimizer.py +++ b/mlos_core/mlos_core/optimizers/bayesian_optimizers/smac_optimizer.py @@ -139,6 +139,7 @@ def __init__( except TypeError: self._temp_output_directory = TemporaryDirectory() output_directory = self._temp_output_directory.name + assert output_directory is not None if n_random_init is not None: assert isinstance(n_random_init, int) and n_random_init >= 0 diff --git a/mlos_viz/mlos_viz/base.py b/mlos_viz/mlos_viz/base.py index e7a52f1bd8c..a6537e82658 100644 --- a/mlos_viz/mlos_viz/base.py +++ b/mlos_viz/mlos_viz/base.py @@ -70,7 +70,7 @@ def _add_groupby_desc_column( groupby_columns = ["tunable_config_trial_group_id", "tunable_config_id"] groupby_column = ",".join(groupby_columns) results_df[groupby_column] = ( - results_df[groupby_columns].astype(str).apply(lambda x: ",".join(x), axis=1) + results_df[groupby_columns].astype(str).apply(",".join, axis=1) ) # pylint: disable=unnecessary-lambda groupby_columns.append(groupby_column) return (results_df, groupby_columns, groupby_column) @@ -418,7 +418,7 @@ def plot_optimizer_trends( else "" ) plt.grid() - plt.show() # type: ignore[no-untyped-call] + plt.show() def plot_top_n_configs( @@ -496,4 +496,4 @@ def plot_top_n_configs( plt.yscale("log") extra_title = "(lower is better)" if ascending else "(lower is better)" plt.title(f"Top {top_n} configs {opt_tgt} {extra_title}") - plt.show() # type: ignore[no-untyped-call] + plt.show() From 8de0a2f1d14eeb0bb2d7262c6cde61af2700e0d3 Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Mon, 25 Nov 2024 12:40:58 -0800 Subject: [PATCH 34/36] Pylint fixups (#886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title Additional fixups for the most recent versions of python/pylint. --- ## Description - Converts some quote inconsistency ignores to `"""` strings - Ignores a `Generator` typehint issue that changed with 3.13 - Adjusts a comment in pyproject.toml to help us track what needs to change after dropping support for 3.8 - #749. --- ## Type of Change - 🔄 Refactor --- --- mlos_bench/mlos_bench/optimizers/base_optimizer.py | 2 +- mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py | 2 +- .../services/remote/azure/azure_network_services.py | 2 +- .../mlos_bench/services/remote/azure/azure_vm_services.py | 5 +++-- mlos_bench/mlos_bench/services/remote/ssh/ssh_service.py | 4 ++-- mlos_bench/mlos_bench/storage/util.py | 4 ++-- mlos_bench/mlos_bench/tests/dict_templater_test.py | 8 ++++---- pyproject.toml | 3 ++- 8 files changed, 16 insertions(+), 14 deletions(-) diff --git a/mlos_bench/mlos_bench/optimizers/base_optimizer.py b/mlos_bench/mlos_bench/optimizers/base_optimizer.py index 14eb3eba6ce..25824043daf 100644 --- a/mlos_bench/mlos_bench/optimizers/base_optimizer.py +++ b/mlos_bench/mlos_bench/optimizers/base_optimizer.py @@ -103,7 +103,7 @@ def _validate_json_config(self, config: dict) -> None: def __repr__(self) -> str: opt_targets = ",".join( - f"{opt_target}:{({1: 'min', -1: 'max'}[opt_dir])}" + f"""{opt_target}:{({1: "min", -1: "max"}[opt_dir])}""" for (opt_target, opt_dir) in self._opt_targets.items() ) return f"{self.name}({opt_targets},config={self._config})" diff --git a/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py b/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py index f9d5685ae8f..327c0e6aba7 100644 --- a/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py +++ b/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py @@ -65,7 +65,7 @@ def __init__( if "max_trials" not in self._config: self._config["max_trials"] = self._max_suggestions assert int(self._config["max_trials"]) >= self._max_suggestions, ( - f"max_trials {self._config.get('max_trials')} " + f"""max_trials {self._config.get("max_trials")} """ f"<= max_suggestions{self._max_suggestions}" ) diff --git a/mlos_bench/mlos_bench/services/remote/azure/azure_network_services.py b/mlos_bench/mlos_bench/services/remote/azure/azure_network_services.py index 4f11e89aa2f..de1a5a7118a 100644 --- a/mlos_bench/mlos_bench/services/remote/azure/azure_network_services.py +++ b/mlos_bench/mlos_bench/services/remote/azure/azure_network_services.py @@ -84,7 +84,7 @@ def _set_default_params(self, params: dict) -> dict: # pylint: disable=no-self- # since this is a common way to set the deploymentName and can same some # config work for the caller. if "vnetName" in params and "deploymentName" not in params: - params["deploymentName"] = f"{params['vnetName']}-deployment" + params["deploymentName"] = f"""{params["vnetName"]}-deployment""" _LOG.info( "deploymentName missing from params. Defaulting to '%s'.", params["deploymentName"], diff --git a/mlos_bench/mlos_bench/services/remote/azure/azure_vm_services.py b/mlos_bench/mlos_bench/services/remote/azure/azure_vm_services.py index 856f5e99126..ff24cbaa66f 100644 --- a/mlos_bench/mlos_bench/services/remote/azure/azure_vm_services.py +++ b/mlos_bench/mlos_bench/services/remote/azure/azure_vm_services.py @@ -190,7 +190,8 @@ def _set_default_params(self, params: dict) -> dict: # pylint: disable=no-self- # since this is a common way to set the deploymentName and can same some # config work for the caller. if "vmName" in params and "deploymentName" not in params: - params["deploymentName"] = f"{params['vmName']}-deployment" + params["deploymentName"] = f"""{params["vmName"]}-deployment""" + _LOG.info( "deploymentName missing from params. Defaulting to '%s'.", params["deploymentName"], @@ -239,7 +240,7 @@ def wait_host_operation(self, params: dict) -> Tuple[Status, dict]: """ _LOG.info("Wait for operation on VM %s", params["vmName"]) # Try and provide a semi sane default for the deploymentName - params.setdefault(f"{params['vmName']}-deployment") + params.setdefault(f"""{params["vmName"]}-deployment""") return self._wait_while(self._check_operation_status, Status.RUNNING, params) def wait_remote_exec_operation(self, params: dict) -> Tuple["Status", dict]: diff --git a/mlos_bench/mlos_bench/services/remote/ssh/ssh_service.py b/mlos_bench/mlos_bench/services/remote/ssh/ssh_service.py index 7e33d715ec3..c0590e55cfa 100644 --- a/mlos_bench/mlos_bench/services/remote/ssh/ssh_service.py +++ b/mlos_bench/mlos_bench/services/remote/ssh/ssh_service.py @@ -70,8 +70,8 @@ def id_from_connection(connection: SSHClientConnection) -> str: def id_from_params(connect_params: dict) -> str: """Gets a unique id repr for the connection.""" return ( - f"{connect_params.get('username')}@{connect_params['host']}" - f":{connect_params.get('port')}" + f"""{connect_params.get("username")}@{connect_params["host"]}""" + f""":{connect_params.get("port")}""" ) def connection_made(self, conn: SSHClientConnection) -> None: diff --git a/mlos_bench/mlos_bench/storage/util.py b/mlos_bench/mlos_bench/storage/util.py index 173f7d95d69..e1c6c1fa054 100644 --- a/mlos_bench/mlos_bench/storage/util.py +++ b/mlos_bench/mlos_bench/storage/util.py @@ -30,10 +30,10 @@ def kv_df_to_dict(dataframe: pandas.DataFrame) -> Dict[str, Optional[TunableValu data = {} for _, row in dataframe.astype("O").iterrows(): if not isinstance(row["value"], TunableValueTypeTuple): - raise TypeError(f"Invalid column type: {type(row['value'])} value: {row['value']}") + raise TypeError(f"""Invalid column type: {type(row["value"])} value: {row["value"]}""") assert isinstance(row["parameter"], str) if row["parameter"] in data: - raise ValueError(f"Duplicate parameter '{row['parameter']}' in dataframe") + raise ValueError(f"""Duplicate parameter '{row["parameter"]}' in dataframe""") data[row["parameter"]] = ( try_parse_val(row["value"]) if isinstance(row["value"], str) else row["value"] ) diff --git a/mlos_bench/mlos_bench/tests/dict_templater_test.py b/mlos_bench/mlos_bench/tests/dict_templater_test.py index 588d1f0ee40..52ec21bd5ae 100644 --- a/mlos_bench/mlos_bench/tests/dict_templater_test.py +++ b/mlos_bench/mlos_bench/tests/dict_templater_test.py @@ -85,7 +85,7 @@ def test_os_env_expansion(source_template_dict: Dict[str, Any]) -> None: results = DictTemplater(source_template_dict).expand_vars(use_os_env=True) assert results == { - "extra_str-ref": f"{environ['extra_str']}-ref", # pylint: disable=inconsistent-quotes + "extra_str-ref": f"""{environ["extra_str"]}-ref""", "str": "string", "str_ref": "string-ref", "secondary_expansion": "string-ref", @@ -103,7 +103,7 @@ def test_os_env_expansion(source_template_dict: Dict[str, Any]) -> None: ], "dict": { "nested-str-ref": "nested-string-ref", - "nested-extra-str-ref": f"nested-{environ['extra_str']}-ref", + "nested-extra-str-ref": f"""nested-{environ["extra_str"]}-ref""", }, } @@ -116,7 +116,7 @@ def test_from_extras_expansion(source_template_dict: Dict[str, Any]) -> None: } results = DictTemplater(source_template_dict).expand_vars(extra_source_dict=extra_source_dict) assert results == { - "extra_str-ref": f"{extra_source_dict['extra_str']}-ref", # pylint: disable=inconsistent-quotes # noqa: E501 + "extra_str-ref": f"""{extra_source_dict["extra_str"]}-ref""", "str": "string", "str_ref": "string-ref", "secondary_expansion": "string-ref", @@ -134,6 +134,6 @@ def test_from_extras_expansion(source_template_dict: Dict[str, Any]) -> None: ], "dict": { "nested-str-ref": "nested-string-ref", - "nested-extra-str-ref": f"nested-{extra_source_dict['extra_str']}-ref", # pylint: disable=inconsistent-quotes # noqa: E501 + "nested-extra-str-ref": f"""nested-{extra_source_dict["extra_str"]}-ref""", }, } diff --git a/pyproject.toml b/pyproject.toml index b5014df8a87..04595e1ed9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,10 +59,11 @@ disable = [ "fixme", "no-else-return", "consider-using-assignment-expr", - "deprecated-typing-alias", # disable for now - only deprecated recently + "deprecated-typing-alias", # disable for now - still supporting python 3.8 "docstring-first-line-empty", "consider-alternative-union-syntax", # disable for now - still supporting python 3.8 "missing-raises-doc", + "unnecessary-default-type-args", # affects Generator type hints, but we still support python 3.8 ] [tool.pylint.string] From 644a71845309100f65a64f60d3a29fe56edba7ca Mon Sep 17 00:00:00 2001 From: Brian Kroth Date: Mon, 25 Nov 2024 12:57:21 -0800 Subject: [PATCH 35/36] Ignore some pytest warnings (#888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## Title Ignores some warnings in the pytest output that come from dependencies. --- ## Description Purely cosmetic to make it easier to see real errors. --- ## Type of Change - 🧪 Tests --- --- mlos_bench/mlos_bench/tests/launcher_in_process_test.py | 3 +++ setup.cfg | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/mlos_bench/mlos_bench/tests/launcher_in_process_test.py b/mlos_bench/mlos_bench/tests/launcher_in_process_test.py index 787a9eea451..38d8c1a6101 100644 --- a/mlos_bench/mlos_bench/tests/launcher_in_process_test.py +++ b/mlos_bench/mlos_bench/tests/launcher_in_process_test.py @@ -62,6 +62,9 @@ ), ], ) +@pytest.mark.filterwarnings( + "ignore:.*(Configuration.*was already registered).*:UserWarning:.*flaml_optimizer.*:0" +) def test_main_bench(argv: List[str], expected_score: float) -> None: """Run mlos_bench optimization loop with given config and check the results.""" (score, _config) = _main(argv) diff --git a/setup.cfg b/setup.cfg index f8b0c945f2d..fd42321bb24 100644 --- a/setup.cfg +++ b/setup.cfg @@ -47,12 +47,15 @@ addopts = testpaths = mlos_core mlos_bench mlos_viz # Ignore some upstream deprecation warnings. filterwarnings = - ignore:.*(get_hyperparam|get_dictionary|get_parents_of|(list\(.*values\(\)\))).*:DeprecationWarning:smac:0 + ignore:.*(builtin type (swigvarlink|SwigPyObject|SwigPyPacked) has no __module__ attribute):DeprecationWarning:.*:0 + ignore:.*(get_hyperparam|get_dictionary|parents_of|to_vector|(list\(.*values\(\)\))).*:DeprecationWarning:smac:0 ignore:.*(Please leave at default or explicitly set .size=None).*:DeprecationWarning:smac:0 + ignore:.*(declarative_base.*function is now available as sqlalchemy.orm.declarative_base):DeprecationWarning:optuna:0 ignore:.*(Trying to register a configuration that was not previously suggested).*:UserWarning:.*llamatune.*:0 ignore:.*(DISPLAY environment variable is set).*:UserWarning:.*conftest.*:0 ignore:.*(coroutine 'sleep' was never awaited).*:RuntimeWarning:.*event_loop_context_test.*:0 ignore:.*(coroutine 'sleep' was never awaited).*:RuntimeWarning:.*test_ssh_service.*:0 + ignore:.*(Configuration.*was already registered).*:UserWarning:.*flaml_optimizer.*:0 [coverage:report] exclude_also = From b66e1347ad0dc82f1e98ecda87de7ba24bfea9f4 Mon Sep 17 00:00:00 2001 From: Johannes Freischuetz Date: Mon, 25 Nov 2024 15:17:58 -0600 Subject: [PATCH 36/36] Rewrite for named tuples (#852) ## Title Refactor mlos_core APIs to encapsulate related data fields. ## Description Refactors the mlos_core Optimizer APIs to accept new data types `Observation`, `Observations` and return `Suggestion`, instead of a mess of `Tuple[DataFrame, DataFrame, Optional[DataFrame], Optional[DataFrame]]` that must be named and checked everywhere. Additionally, this makes it more explicit that `_register` is a bulk operation that is not actually supported currently by the underlying optimizers, though leaves notes on how we can do that in the future. ## Type of Change - Refactor --- ## Testing Usual CI plus some new unit tests for new data type operations. --- ## Additional Notes A more significant rewrite of named tuple support inside mlos_core. This is based on comments in #811 as well as conversations with @bpkroth --- --------- Co-authored-by: Brian Kroth Co-authored-by: Brian Kroth --- .cspell.json | 1 + .../optimizers/mlos_core_optimizer.py | 23 +- .../optimizers/toy_optimization_loop_test.py | 10 +- mlos_core/mlos_core/__init__.py | 41 +- mlos_core/mlos_core/data_classes.py | 414 ++++++++++++++++++ .../bayesian_optimizers/bayesian_optimizer.py | 33 +- .../bayesian_optimizers/smac_optimizer.py | 155 ++++--- .../mlos_core/optimizers/flaml_optimizer.py | 100 ++--- mlos_core/mlos_core/optimizers/optimizer.py | 213 +++++---- .../mlos_core/optimizers/random_optimizer.py | 69 ++- .../mlos_core/spaces/adapters/adapter.py | 24 +- .../spaces/adapters/identity_adapter.py | 6 +- .../mlos_core/spaces/adapters/llamatune.py | 109 +++-- .../optimizers/bayesian_optimizers_test.py | 11 +- .../tests/optimizers/data_class_test.py | 295 +++++++++++++ .../optimizers/optimizer_multiobj_test.py | 71 +-- .../tests/optimizers/optimizer_test.py | 202 +++++---- .../spaces/adapters/identity_adapter_test.py | 22 +- .../tests/spaces/adapters/llamatune_test.py | 69 ++- mlos_core/mlos_core/util.py | 58 ++- .../notebooks/BayesianOptimization.ipynb | 144 +++--- 21 files changed, 1393 insertions(+), 677 deletions(-) create mode 100644 mlos_core/mlos_core/data_classes.py create mode 100644 mlos_core/mlos_core/tests/optimizers/data_class_test.py diff --git a/.cspell.json b/.cspell.json index 2cd9280fc8d..f4bc99063c2 100644 --- a/.cspell.json +++ b/.cspell.json @@ -21,6 +21,7 @@ "discretization", "discretize", "drivername", + "dropna", "dstpath", "dtype", "duckdb", diff --git a/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py b/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py index 327c0e6aba7..9da5761d6d0 100644 --- a/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py +++ b/mlos_bench/mlos_bench/optimizers/mlos_core_optimizer.py @@ -21,6 +21,7 @@ from mlos_bench.services.base_service import Service from mlos_bench.tunables.tunable import TunableValue from mlos_bench.tunables.tunable_groups import TunableGroups +from mlos_core.data_classes import Observations from mlos_core.optimizers import ( DEFAULT_OPTIMIZER_TYPE, BaseOptimizer, @@ -128,7 +129,7 @@ def bulk_register( # TODO: Specify (in the config) which metrics to pass to the optimizer. # Issue: https://github.com/microsoft/MLOS/issues/745 - self._opt.register(configs=df_configs, scores=df_scores) + self._opt.register(observations=Observations(configs=df_configs, scores=df_scores)) if _LOG.isEnabledFor(logging.DEBUG): (score, _) = self.get_best_observation() @@ -198,10 +199,10 @@ def suggest(self) -> TunableGroups: tunables = super().suggest() if self._start_with_defaults: _LOG.info("Use default values for the first trial") - df_config, _metadata = self._opt.suggest(defaults=self._start_with_defaults) + suggestion = self._opt.suggest(defaults=self._start_with_defaults) self._start_with_defaults = False - _LOG.info("Iteration %d :: Suggest:\n%s", self._iter, df_config) - return tunables.assign(configspace_data_to_tunable_values(df_config.loc[0].to_dict())) + _LOG.info("Iteration %d :: Suggest:\n%s", self._iter, suggestion.config) + return tunables.assign(configspace_data_to_tunable_values(suggestion.config.to_dict())) def register( self, @@ -221,18 +222,20 @@ def register( # TODO: Specify (in the config) which metrics to pass to the optimizer. # Issue: https://github.com/microsoft/MLOS/issues/745 self._opt.register( - configs=df_config, - scores=pd.DataFrame([registered_score], dtype=float), + observations=Observations( + configs=df_config, + scores=pd.DataFrame([registered_score], dtype=float), + ) ) return registered_score def get_best_observation( self, ) -> Union[Tuple[Dict[str, float], TunableGroups], Tuple[None, None]]: - (df_config, df_score, _df_context) = self._opt.get_best_observations() - if len(df_config) == 0: + best_observations = self._opt.get_best_observations() + if len(best_observations) == 0: return (None, None) - params = configspace_data_to_tunable_values(df_config.iloc[0].to_dict()) - scores = self._adjust_signs_df(df_score).iloc[0].to_dict() + params = configspace_data_to_tunable_values(best_observations.configs.iloc[0].to_dict()) + scores = self._adjust_signs_df(best_observations.scores).iloc[0].to_dict() _LOG.debug("Best observation: %s score: %s", params, scores) return (scores, self._tunables.copy().assign(params)) diff --git a/mlos_bench/mlos_bench/tests/optimizers/toy_optimization_loop_test.py b/mlos_bench/mlos_bench/tests/optimizers/toy_optimization_loop_test.py index db46189e442..5257c1ebd23 100644 --- a/mlos_bench/mlos_bench/tests/optimizers/toy_optimization_loop_test.py +++ b/mlos_bench/mlos_bench/tests/optimizers/toy_optimization_loop_test.py @@ -16,8 +16,9 @@ from mlos_bench.optimizers.mlos_core_optimizer import MlosCoreOptimizer from mlos_bench.optimizers.mock_optimizer import MockOptimizer from mlos_bench.tunables.tunable_groups import TunableGroups +from mlos_core.data_classes import Suggestion from mlos_core.optimizers.bayesian_optimizers.smac_optimizer import SmacOptimizer -from mlos_core.util import config_to_dataframe +from mlos_core.util import config_to_series # For debugging purposes output some warnings which are captured with failed tests. DEBUG = True @@ -40,10 +41,13 @@ def _optimize(env: Environment, opt: Optimizer) -> Tuple[float, TunableGroups]: # pylint: disable=protected-access if isinstance(opt, MlosCoreOptimizer) and isinstance(opt._opt, SmacOptimizer): config = tunable_values_to_configuration(tunables) - config_df = config_to_dataframe(config) + config_series = config_to_series(config) logger("config: %s", str(config)) try: - logger("prediction: %s", opt._opt.surrogate_predict(configs=config_df)) + logger( + "prediction: %s", + opt._opt.surrogate_predict(suggestion=Suggestion(config=config_series)), + ) except RuntimeError: pass diff --git a/mlos_core/mlos_core/__init__.py b/mlos_core/mlos_core/__init__.py index 13b1bf2af82..8a315cc92a1 100644 --- a/mlos_core/mlos_core/__init__.py +++ b/mlos_core/mlos_core/__init__.py @@ -62,39 +62,38 @@ ... space_adapter_kwargs=space_adpaters_kwargs, ... ) >>> # Get a new configuration suggestion. ->>> (config_df, _metadata_df) = opt.suggest() +>>> suggestion = opt.suggest() >>> # Examine the suggested configuration. ->>> assert len(config_df) == 1 ->>> config_df.iloc[0] +>>> assert len(suggestion.config) == 1 +>>> suggestion.config x 3 -Name: 0, dtype: int64 +dtype: object >>> # Register the configuration and its corresponding target value >>> score = 42 # a made up score ->>> scores_df = pandas.DataFrame({"y": [score]}) ->>> opt.register(configs=config_df, scores=scores_df) +>>> scores_sr = pandas.Series({"y": score}) +>>> opt.register(suggestion.complete(scores_sr)) >>> # Get a new configuration suggestion. ->>> (config_df, _metadata_df) = opt.suggest() ->>> config_df.iloc[0] +>>> suggestion = opt.suggest() +>>> suggestion.config x 10 -Name: 0, dtype: int64 +dtype: object >>> score = 7 # a better made up score >>> # Optimizers minimize by convention, so a lower score is better >>> # You can use a negative score to maximize values instead >>> # ->>> # Convert it to a DataFrame again ->>> scores_df = pandas.DataFrame({"y": [score]}) ->>> opt.register(configs=config_df, scores=scores_df) +>>> # Convert it to a Series again +>>> scores_sr = pandas.Series({"y": score}) +>>> opt.register(suggestion.complete(scores_sr)) >>> # Get the best observations. ->>> (configs_df, scores_df, _contexts_df) = opt.get_best_observations() +>>> observations = opt.get_best_observations() >>> # The default is to only return one ->>> assert len(configs_df) == 1 ->>> assert len(scores_df) == 1 ->>> configs_df.iloc[0] -x 10 -Name: 1, dtype: int64 ->>> scores_df.iloc[0] -y 7 -Name: 1, dtype: int64 +>>> assert len(observations) == 1 +>>> observations.configs + x +0 10 +>>> observations.scores + y +0 7 Notes ----- diff --git a/mlos_core/mlos_core/data_classes.py b/mlos_core/mlos_core/data_classes.py new file mode 100644 index 00000000000..1a0a2720ab6 --- /dev/null +++ b/mlos_core/mlos_core/data_classes.py @@ -0,0 +1,414 @@ +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +"""Data classes for mlos_core used to pass around configurations, observations, and +suggestions. +""" +from typing import Any, Iterable, Iterator, Optional + +import pandas as pd +from ConfigSpace import Configuration, ConfigurationSpace + +from mlos_core.util import compare_optional_dataframe, compare_optional_series + + +class Observation: + """A single observation of a configuration.""" + + def __init__( + self, + *, + config: pd.Series, + score: pd.Series = pd.Series(), + context: Optional[pd.Series] = None, + metadata: Optional[pd.Series] = None, + ): + """ + Creates a new Observation object. + + Parameters + ---------- + config : pandas.Series + The configuration observed. + score : pandas.Series + The score metrics observed. + context : Optional[pandas.Series] + The context in which the configuration was evaluated. + Not Yet Implemented. + metadata: Optional[pandas.Series] + The metadata in which the configuration was evaluated + """ + self._config = config + self._score = score + self._context = context + self._metadata = metadata + + @property + def config(self) -> pd.Series: + """Gets (a copy of) the config of the Observation.""" + return self._config.copy() + + @property + def score(self) -> pd.Series: + """Gets (a copy of) the score of the Observation.""" + return self._score.copy() + + @property + def context(self) -> Optional[pd.Series]: + """Gets (a copy of) the context of the Observation.""" + return self._context.copy() if self._context is not None else None + + @property + def metadata(self) -> Optional[pd.Series]: + """Gets (a copy of) the metadata of the Observation.""" + return self._metadata.copy() if self._metadata is not None else None + + def to_suggestion(self) -> "Suggestion": + """ + Converts the observation to a suggestion. + + Returns + ------- + Suggestion + The suggestion. + """ + return Suggestion( + config=self.config, + context=self.context, + metadata=self.metadata, + ) + + def __repr__(self) -> str: + return ( + f"Observation(config={self._config}, score={self._score}, " + f"context={self._context}, metadata={self._metadata})" + ) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Observation): + return False + + if not self._config.equals(other._config): + return False + if not self._score.equals(other._score): + return False + if not compare_optional_series(self._context, other._context): + return False + if not compare_optional_series(self._metadata, other._metadata): + return False + + return True + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + + +class Observations: + """A set of observations of a configuration scores.""" + + def __init__( # pylint: disable=too-many-arguments + self, + *, + configs: pd.DataFrame = pd.DataFrame(), + scores: pd.DataFrame = pd.DataFrame(), + contexts: Optional[pd.DataFrame] = None, + metadata: Optional[pd.DataFrame] = None, + observations: Optional[Iterable[Observation]] = None, + ): + """ + Creates a new Observation object. + + Can accept either a set of Observations or a collection of aligned config and + score (and optionally context) dataframes. + + If both are provided the two sets will be merged. + + Parameters + ---------- + configs : pandas.DataFrame + Pandas dataframe containing configurations. Column names are the parameter names. + scores : pandas.DataFrame + The score metrics observed in a dataframe. + contexts : Optional[pandas.DataFrame] + The context in which the configuration was evaluated. + Not Yet Implemented. + metadata: Optional[pandas.DataFrame] + The metadata in which the configuration was evaluated + Not Yet Implemented. + """ + if observations is None: + observations = [] + if any(observations): + configs = pd.concat([obs.config.to_frame().T for obs in observations]) + scores = pd.concat([obs.score.to_frame().T for obs in observations]) + + if sum(obs.context is None for obs in observations) == 0: + contexts = pd.concat( + [obs.context.to_frame().T for obs in observations] # type: ignore[union-attr] + ) + else: + contexts = None + if sum(obs.metadata is None for obs in observations) == 0: + metadata = pd.concat( + [obs.metadata.to_frame().T for obs in observations] # type: ignore[union-attr] + ) + else: + metadata = None + assert len(configs.index) == len( + scores.index + ), "config and score must have the same length" + if contexts is not None: + assert len(configs.index) == len( + contexts.index + ), "config and context must have the same length" + if metadata is not None: + assert len(configs.index) == len( + metadata.index + ), "config and metadata must have the same length" + self._configs = configs.reset_index(drop=True) + self._scores = scores.reset_index(drop=True) + self._contexts = None if contexts is None else contexts.reset_index(drop=True) + self._metadata = None if metadata is None else metadata.reset_index(drop=True) + + @property + def configs(self) -> pd.DataFrame: + """Gets a copy of the configs of the Observations.""" + return self._configs.copy() + + @property + def scores(self) -> pd.DataFrame: + """Gets a copy of the scores of the Observations.""" + return self._scores.copy() + + @property + def contexts(self) -> Optional[pd.DataFrame]: + """Gets a copy of the contexts of the Observations.""" + return self._contexts.copy() if self._contexts is not None else None + + @property + def metadata(self) -> Optional[pd.DataFrame]: + """Gets a copy of the metadata of the Observations.""" + return self._metadata.copy() if self._metadata is not None else None + + def filter_by_index(self, index: pd.Index) -> "Observations": + """ + Filters the observation by the given indices. + + Parameters + ---------- + index : pandas.Index + The indices to filter by. + + Returns + ------- + Observation + The filtered observation. + """ + return Observations( + configs=self._configs.loc[index].copy(), + scores=self._scores.loc[index].copy(), + contexts=None if self._contexts is None else self._contexts.loc[index].copy(), + metadata=None if self._metadata is None else self._metadata.loc[index].copy(), + ) + + def append(self, observation: Observation) -> None: + """ + Appends the given observation to this observation. + + Parameters + ---------- + observation : Observation + The observation to append. + """ + config = observation.config.to_frame().T + score = observation.score.to_frame().T + context = None if observation.context is None else observation.context.to_frame().T + metadata = None if observation.metadata is None else observation.metadata.to_frame().T + if len(self._configs.index) == 0: + self._configs = config + self._scores = score + self._contexts = context + self._metadata = metadata + assert set(self.configs.index) == set( + self.scores.index + ), "config and score must have the same index" + return + + self._configs = pd.concat([self._configs, config]).reset_index(drop=True) + self._scores = pd.concat([self._scores, score]).reset_index(drop=True) + assert set(self.configs.index) == set( + self.scores.index + ), "config and score must have the same index" + + if self._contexts is not None: + assert context is not None, ( + "context of appending observation must not be null " + "if context of prior observation is not null" + ) + self._contexts = pd.concat([self._contexts, context]).reset_index(drop=True) + assert self._configs.index.equals( + self._contexts.index + ), "config and context must have the same index" + else: + assert context is None, ( + "context of appending observation must be null " + "if context of prior observation is null" + ) + if self._metadata is not None: + assert metadata is not None, ( + "context of appending observation must not be null " + "if metadata of prior observation is not null" + ) + self._metadata = pd.concat([self._metadata, metadata]).reset_index(drop=True) + assert self._configs.index.equals( + self._metadata.index + ), "config and metadata must have the same index" + else: + assert metadata is None, ( + "context of appending observation must be null " + "if metadata of prior observation is null" + ) + + def __len__(self) -> int: + return len(self._configs.index) + + def __iter__(self) -> Iterator["Observation"]: + for idx in self._configs.index: + yield Observation( + config=self._configs.loc[idx], + score=self._scores.loc[idx], + context=None if self._contexts is None else self._contexts.loc[idx], + metadata=None if self._metadata is None else self._metadata.loc[idx], + ) + + def __repr__(self) -> str: + return ( + f"Observation(configs={self._configs}, score={self._scores}, " + "contexts={self._contexts}, metadata={self._metadata})" + ) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Observations): + return False + + if not self._configs.equals(other._configs): + return False + if not self._scores.equals(other._scores): + return False + if not compare_optional_dataframe(self._contexts, other._contexts): + return False + if not compare_optional_dataframe(self._metadata, other._metadata): + return False + + return True + + # required as per: https://stackoverflow.com/questions/30643236/does-ne-use-an-overridden-eq + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + + +class Suggestion: + """ + A single suggestion for a configuration. + + A Suggestion is an Observation that has not yet been scored. Evaluating the + Suggestion and calling `complete(scores)` can convert it to an Observation. + """ + + def __init__( + self, + *, + config: pd.Series, + context: Optional[pd.Series] = None, + metadata: Optional[pd.Series] = None, + ): + """ + Creates a new Suggestion. + + Parameters + ---------- + config : pandas.Series + The configuration suggested. + context : Optional[pandas.Series] + The context for this suggestion, by default None + metadata : Optional[pandas.Series] + Any metadata provided by the underlying optimizer, by default None + """ + self._config = config + self._context = context + self._metadata = metadata + + @property + def config(self) -> pd.Series: + """Gets (a copy of) the config of the Suggestion.""" + return self._config.copy() + + @property + def context(self) -> Optional[pd.Series]: + """Gets (a copy of) the context of the Suggestion.""" + return self._context.copy() if self._context is not None else None + + @property + def metadata(self) -> Optional[pd.Series]: + """Gets (a copy of) the metadata of the Suggestion.""" + return self._metadata.copy() if self._metadata is not None else None + + def complete(self, score: pd.Series) -> Observation: + """ + Completes the Suggestion by adding a score to turn it into an Observation. + + Parameters + ---------- + score : pandas.Series + The score metrics observed. + + Returns + ------- + Observation + The observation of the suggestion. + """ + return Observation( + config=self.config, + score=score, + context=self.context, + metadata=self.metadata, + ) + + def to_configspace_config(self, space: ConfigurationSpace) -> Configuration: + """ + Convert a Configuration Space to a Configuration. + + Parameters + ---------- + space : ConfigSpace.ConfigurationSpace + The ConfigurationSpace to be converted. + + Returns + ------- + ConfigSpace.Configuration + The output Configuration. + """ + return Configuration(space, values=self._config.dropna().to_dict()) + + def __repr__(self) -> str: + return ( + f"Suggestion(config={self._config}, context={self._context}, " + "metadata={self._metadata})" + ) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Suggestion): + return False + + if not self._config.equals(other._config): + return False + if not compare_optional_series(self._context, other._context): + return False + if not compare_optional_series(self._metadata, other._metadata): + return False + + return True + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) diff --git a/mlos_core/mlos_core/optimizers/bayesian_optimizers/bayesian_optimizer.py b/mlos_core/mlos_core/optimizers/bayesian_optimizers/bayesian_optimizer.py index cfdde1656dd..d828030b7f1 100644 --- a/mlos_core/mlos_core/optimizers/bayesian_optimizers/bayesian_optimizer.py +++ b/mlos_core/mlos_core/optimizers/bayesian_optimizers/bayesian_optimizer.py @@ -5,11 +5,10 @@ """Contains the wrapper classes for base Bayesian optimizers.""" from abc import ABCMeta, abstractmethod -from typing import Optional import numpy.typing as npt -import pandas as pd +from mlos_core.data_classes import Suggestion from mlos_core.optimizers.optimizer import BaseOptimizer @@ -17,45 +16,27 @@ class BaseBayesianOptimizer(BaseOptimizer, metaclass=ABCMeta): """Abstract base class defining the interface for Bayesian optimization.""" @abstractmethod - def surrogate_predict( - self, - *, - configs: pd.DataFrame, - context: Optional[pd.DataFrame] = None, - ) -> npt.NDArray: + def surrogate_predict(self, suggestion: Suggestion) -> npt.NDArray: """ Obtain a prediction from this Bayesian optimizer's surrogate model for the given configuration(s). Parameters ---------- - configs : pandas.DataFrame - Dataframe of configs / parameters. The columns are parameter names and - the rows are the configs. - - context : pandas.DataFrame - Not Yet Implemented. + suggestion: Suggestion + The suggestion containing the configuration(s) to predict. """ pass # pylint: disable=unnecessary-pass # pragma: no cover @abstractmethod - def acquisition_function( - self, - *, - configs: pd.DataFrame, - context: Optional[pd.DataFrame] = None, - ) -> npt.NDArray: + def acquisition_function(self, suggestion: Suggestion) -> npt.NDArray: """ Invokes the acquisition function from this Bayesian optimizer for the given configuration. Parameters ---------- - configs : pandas.DataFrame - Dataframe of configs / parameters. The columns are parameter names and - the rows are the configs. - - context : pandas.DataFrame - Not Yet Implemented. + suggestion: Suggestion + The suggestion containing the configuration(s) to evaluate. """ pass # pylint: disable=unnecessary-pass # pragma: no cover diff --git a/mlos_core/mlos_core/optimizers/bayesian_optimizers/smac_optimizer.py b/mlos_core/mlos_core/optimizers/bayesian_optimizers/smac_optimizer.py index 05f0b523642..8c730372616 100644 --- a/mlos_core/mlos_core/optimizers/bayesian_optimizers/smac_optimizer.py +++ b/mlos_core/mlos_core/optimizers/bayesian_optimizers/smac_optimizer.py @@ -14,19 +14,20 @@ from logging import warning from pathlib import Path from tempfile import TemporaryDirectory -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Union from warnings import warn import ConfigSpace import numpy.typing as npt import pandas as pd +from smac.utils.configspace import convert_configurations_to_array +from mlos_core.data_classes import Observation, Observations, Suggestion from mlos_core.optimizers.bayesian_optimizers.bayesian_optimizer import ( BaseBayesianOptimizer, ) from mlos_core.spaces.adapters.adapter import BaseSpaceAdapter from mlos_core.spaces.adapters.identity_adapter import IdentityAdapter -from mlos_core.util import drop_nulls class SmacOptimizer(BaseBayesianOptimizer): @@ -292,30 +293,33 @@ def _dummy_target_func(config: ConfigSpace.Configuration, seed: int = 0) -> None def _register( self, - *, - configs: pd.DataFrame, - scores: pd.DataFrame, - context: Optional[pd.DataFrame] = None, - metadata: Optional[pd.DataFrame] = None, + observations: Observations, ) -> None: """ - Registers the given configs and scores. + Registers one or more configs/score pairs (observations) with the underlying + optimizer. Parameters ---------- - configs : pd.DataFrame - Dataframe of configs / parameters. The columns are parameter names and - the rows are the configs. - - scores : pd.DataFrame - Scores from running the configs. The index is the same as the index of - the configs. + observations : Observations + The set of config/scores to register. + """ + # TODO: Implement bulk registration. + # (e.g., by rebuilding the base optimizer instance with all observations). + for observation in observations: + self._register_single(observation) - context : pd.DataFrame - Not Yet Implemented. + def _register_single( + self, + observation: Observation, + ) -> None: + """ + Registers the given config and its score. - metadata: pd.DataFrame - Not Yet Implemented. + Parameters + ---------- + observation: Observation + The observation to register. """ from smac.runhistory import ( # pylint: disable=import-outside-toplevel StatusType, @@ -323,21 +327,28 @@ def _register( TrialValue, ) - if context is not None: - warn(f"Not Implemented: Ignoring context {list(context.columns)}", UserWarning) - - # Register each trial (one-by-one) - for config, (_i, score) in zip( - self._to_configspace_configs(configs=configs), scores.iterrows() - ): - # Retrieve previously generated TrialInfo (returned by .ask()) or create - # new TrialInfo instance - info: TrialInfo = self.trial_info_map.get( - config, - TrialInfo(config=config, seed=self.base_optimizer.scenario.seed), + if observation.context is not None: + warn( + f"Not Implemented: Ignoring context {list(observation.context.index)}", + UserWarning, ) - value = TrialValue(cost=list(score.astype(float)), time=0.0, status=StatusType.SUCCESS) - self.base_optimizer.tell(info, value, save=False) + + # Retrieve previously generated TrialInfo (returned by .ask()) or create + # new TrialInfo instance + config = ConfigSpace.Configuration( + self.optimizer_parameter_space, + values=observation.config.dropna().to_dict(), + ) + info: TrialInfo = self.trial_info_map.get( + config, + TrialInfo(config=config, seed=self.base_optimizer.scenario.seed), + ) + value = TrialValue( + cost=list(observation.score.astype(float)), + time=0.0, + status=StatusType.SUCCESS, + ) + self.base_optimizer.tell(info, value, save=False) # Save optimizer once we register all configs self.base_optimizer.optimizer.save() @@ -345,8 +356,8 @@ def _register( def _suggest( self, *, - context: Optional[pd.DataFrame] = None, - ) -> Tuple[pd.DataFrame, Optional[pd.DataFrame]]: + context: Optional[pd.Series] = None, + ) -> Suggestion: """ Suggests a new configuration. @@ -357,18 +368,15 @@ def _suggest( Returns ------- - configuration : pd.DataFrame - Pandas dataframe with a single row. Column names are the parameter names. - - metadata : Optional[pd.DataFrame] - Not yet implemented. + suggestion: Suggestion + The suggestion to evaluate. """ if TYPE_CHECKING: # pylint: disable=import-outside-toplevel,unused-import from smac.runhistory import TrialInfo if context is not None: - warn(f"Not Implemented: Ignoring context {list(context.columns)}", UserWarning) + warn(f"Not Implemented: Ignoring context {list(context.index)}", UserWarning) trial: TrialInfo = self.base_optimizer.ask() trial.config.check_valid_configuration() @@ -378,31 +386,18 @@ def _suggest( ).check_valid_configuration() assert trial.config.config_space == self.optimizer_parameter_space self.trial_info_map[trial.config] = trial - config_df = pd.DataFrame( - [trial.config], columns=list(self.optimizer_parameter_space.keys()) - ) - return config_df, None + config_sr = pd.Series(dict(trial.config), dtype=object) + return Suggestion(config=config_sr, context=context, metadata=None) - def register_pending( - self, - *, - configs: pd.DataFrame, - context: Optional[pd.DataFrame] = None, - metadata: Optional[pd.DataFrame] = None, - ) -> None: + def register_pending(self, pending: Suggestion) -> None: raise NotImplementedError() - def surrogate_predict( - self, - *, - configs: pd.DataFrame, - context: Optional[pd.DataFrame] = None, - ) -> npt.NDArray: - # pylint: disable=import-outside-toplevel - from smac.utils.configspace import convert_configurations_to_array - - if context is not None: - warn(f"Not Implemented: Ignoring context {list(context.columns)}", UserWarning) + def surrogate_predict(self, suggestion: Suggestion) -> npt.NDArray: + if suggestion.context is not None: + warn( + f"Not Implemented: Ignoring context {list(suggestion.context.index)}", + UserWarning, + ) if self._space_adapter and not isinstance(self._space_adapter, IdentityAdapter): raise NotImplementedError("Space adapter not supported for surrogate_predict.") @@ -416,22 +411,24 @@ def surrogate_predict( if self.base_optimizer._config_selector._model is None: raise RuntimeError("Surrogate model is not yet trained") - config_array: npt.NDArray = convert_configurations_to_array( - self._to_configspace_configs(configs=configs) + config_array = convert_configurations_to_array( + [ + ConfigSpace.Configuration( + self.optimizer_parameter_space, values=suggestion.config.to_dict() + ) + ] ) mean_predictions, _ = self.base_optimizer._config_selector._model.predict(config_array) return mean_predictions.reshape( -1, ) - def acquisition_function( - self, - *, - configs: pd.DataFrame, - context: Optional[pd.DataFrame] = None, - ) -> npt.NDArray: - if context is not None: - warn(f"Not Implemented: Ignoring context {list(context.columns)}", UserWarning) + def acquisition_function(self, suggestion: Suggestion) -> npt.NDArray: + if suggestion.context is not None: + warn( + f"Not Implemented: Ignoring context {list(suggestion.context.index)}", + UserWarning, + ) if self._space_adapter: raise NotImplementedError() @@ -439,8 +436,9 @@ def acquisition_function( if self.base_optimizer._config_selector._acquisition_function is None: raise RuntimeError("Acquisition function is not yet initialized") - cs_configs: list = self._to_configspace_configs(configs=configs) - return self.base_optimizer._config_selector._acquisition_function(cs_configs).reshape( + return self.base_optimizer._config_selector._acquisition_function( + suggestion.config.config_to_configspace(self.optimizer_parameter_space) + ).reshape( -1, ) @@ -465,11 +463,6 @@ def _to_configspace_configs(self, *, configs: pd.DataFrame) -> List[ConfigSpace. List of ConfigSpace configs. """ return [ - ConfigSpace.Configuration( - self.optimizer_parameter_space, - # Remove None values for inactive parameters - values=drop_nulls(config.to_dict()), - allow_inactive_with_values=False, - ) + ConfigSpace.Configuration(self.optimizer_parameter_space, values=config.to_dict()) for (_, config) in configs.astype("O").iterrows() ] diff --git a/mlos_core/mlos_core/optimizers/flaml_optimizer.py b/mlos_core/mlos_core/optimizers/flaml_optimizer.py index 6c0b3a25400..5675c0a456b 100644 --- a/mlos_core/mlos_core/optimizers/flaml_optimizer.py +++ b/mlos_core/mlos_core/optimizers/flaml_optimizer.py @@ -11,16 +11,17 @@ details. """ -from typing import Dict, List, NamedTuple, Optional, Tuple, Union +from typing import Dict, List, NamedTuple, Optional, Union from warnings import warn import ConfigSpace import numpy as np import pandas as pd +from mlos_core.data_classes import Observation, Observations, Suggestion from mlos_core.optimizers.optimizer import BaseOptimizer from mlos_core.spaces.adapters.adapter import BaseSpaceAdapter -from mlos_core.util import drop_nulls, normalize_config +from mlos_core.util import normalize_config class EvaluatedSample(NamedTuple): @@ -101,54 +102,62 @@ def __init__( def _register( self, - *, - configs: pd.DataFrame, - scores: pd.DataFrame, - context: Optional[pd.DataFrame] = None, - metadata: Optional[pd.DataFrame] = None, + observations: Observations, ) -> None: """ - Registers the given configs and scores. + Registers one or more configs/score pairs (observations) with the underlying + optimizer. Parameters ---------- - configs : pd.DataFrame - Dataframe of configs / parameters. The columns are parameter names and - the rows are the configs. - - scores : pd.DataFrame - Scores from running the configs. The index is the same as the index of the configs. + observations : Observations + The set of config/scores to register. + """ + # TODO: Implement bulk registration. + # (e.g., by rebuilding the base optimizer instance with all observations). + for observation in observations: + self._register_single(observation) - context : None - Not Yet Implemented. + def _register_single( + self, + observation: Observation, + ) -> None: + """ + Registers the given config and its score. - metadata : None - Not Yet Implemented. + Parameters + ---------- + observation : Observation + The observation to register. """ - if context is not None: - warn(f"Not Implemented: Ignoring context {list(context.columns)}", UserWarning) - if metadata is not None: - warn(f"Not Implemented: Ignoring metadata {list(metadata.columns)}", UserWarning) - - for (_, config), (_, score) in zip(configs.astype("O").iterrows(), scores.iterrows()): - # Remove None values for inactive config parameters - config_dict = drop_nulls(config.to_dict()) - cs_config: ConfigSpace.Configuration = ConfigSpace.Configuration( - self.optimizer_parameter_space, - values=config_dict, + if observation.context is not None: + warn( + f"Not Implemented: Ignoring context {list(observation.context.index)}", + UserWarning, ) - if cs_config in self.evaluated_samples: - warn(f"Configuration {config} was already registered", UserWarning) - self.evaluated_samples[cs_config] = EvaluatedSample( - config=config_dict, - score=float(np.average(score.astype(float), weights=self._objective_weights)), + if observation.metadata is not None: + warn( + f"Not Implemented: Ignoring metadata {list(observation.metadata.index)}", + UserWarning, ) + cs_config: ConfigSpace.Configuration = observation.to_suggestion().to_configspace_config( + self.optimizer_parameter_space + ) + if cs_config in self.evaluated_samples: + warn(f"Configuration {cs_config} was already registered", UserWarning) + self.evaluated_samples[cs_config] = EvaluatedSample( + config=dict(cs_config), + score=float( + np.average(observation.score.astype(float), weights=self._objective_weights) + ), + ) + def _suggest( self, *, - context: Optional[pd.DataFrame] = None, - ) -> Tuple[pd.DataFrame, Optional[pd.DataFrame]]: + context: Optional[pd.Series] = None, + ) -> Suggestion: """ Suggests a new configuration. @@ -161,24 +170,15 @@ def _suggest( Returns ------- - configuration : pd.DataFrame - Pandas dataframe with a single row. Column names are the parameter names. - - metadata : None - Not implemented. + suggestion : Suggestion + The suggestion to be evaluated. """ if context is not None: - warn(f"Not Implemented: Ignoring context {list(context.columns)}", UserWarning) + warn(f"Not Implemented: Ignoring context {list(context.index)}", UserWarning) config: dict = self._get_next_config() - return pd.DataFrame(config, index=[0]), None + return Suggestion(config=pd.Series(config, dtype=object), context=context, metadata=None) - def register_pending( - self, - *, - configs: pd.DataFrame, - context: Optional[pd.DataFrame] = None, - metadata: Optional[pd.DataFrame] = None, - ) -> None: + def register_pending(self, pending: Suggestion) -> None: raise NotImplementedError() def _target_function(self, config: dict) -> Union[dict, None]: diff --git a/mlos_core/mlos_core/optimizers/optimizer.py b/mlos_core/mlos_core/optimizers/optimizer.py index 84f0f8fab61..b8843c5d8cb 100644 --- a/mlos_core/mlos_core/optimizers/optimizer.py +++ b/mlos_core/mlos_core/optimizers/optimizer.py @@ -6,6 +6,7 @@ import collections from abc import ABCMeta, abstractmethod +from copy import deepcopy from typing import List, Optional, Tuple, Union import ConfigSpace @@ -13,8 +14,9 @@ import numpy.typing as npt import pandas as pd +from mlos_core.data_classes import Observation, Observations, Suggestion from mlos_core.spaces.adapters.adapter import BaseSpaceAdapter -from mlos_core.util import config_to_dataframe +from mlos_core.util import config_to_series class BaseOptimizer(metaclass=ABCMeta): @@ -69,7 +71,7 @@ def __init__( raise ValueError("Number of weights must match the number of optimization targets") self._space_adapter: Optional[BaseSpaceAdapter] = space_adapter - self._observations: List[Tuple[pd.DataFrame, pd.DataFrame, Optional[pd.DataFrame]]] = [] + self._observations: Observations = Observations() self._has_context: Optional[bool] = None self._pending_observations: List[Tuple[pd.DataFrame, Optional[pd.DataFrame]]] = [] @@ -83,92 +85,99 @@ def space_adapter(self) -> Optional[BaseSpaceAdapter]: def register( self, - *, - configs: pd.DataFrame, - scores: pd.DataFrame, - context: Optional[pd.DataFrame] = None, - metadata: Optional[pd.DataFrame] = None, + observations: Union[Observation, Observations], ) -> None: """ - Wrapper method, which employs the space adapter (if any), before registering the - configs and scores. + Register all observations at once. Exactly one of observations or observation + must be provided. Parameters ---------- - configs : pandas.DataFrame - Dataframe of configs / parameters. The columns are parameter names and - the rows are the configs. - scores : pandas.DataFrame - Scores from running the configs. The index is the same as the index of the configs. + observations: Optional[Union[Observation, Observations]] + The observations to register. + """ + if isinstance(observations, Observation): + observations = Observations(observations=[observations]) + # Check input and transform the observations if a space adapter is present. + observations = Observations( + observations=[ + self._preprocess_observation(observation) for observation in observations + ] + ) + # Now bulk register all observations (details delegated to the underlying classes). + self._register(observations) - context : pandas.DataFrame - Not Yet Implemented. + def _preprocess_observation(self, observation: Observation) -> Observation: + """ + Wrapper method, which employs the space adapter (if any), and does some input + validation, before registering the configs and scores. + + Parameters + ---------- + observation: Observation + The observation to register. - metadata : Optional[pandas.DataFrame] - Metadata returned by the backend optimizer's suggest method. + Returns + ------- + observation: Observation + The (possibly transformed) observation to register. """ # Do some input validation. - assert metadata is None or isinstance(metadata, pd.DataFrame) - assert set(scores.columns) == set( + assert observation.metadata is None or isinstance(observation.metadata, pd.Series) + assert set(observation.score.index) == set( self._optimization_targets ), "Mismatched optimization targets." assert self._has_context is None or self._has_context ^ ( - context is None + observation.context is None ), "Context must always be added or never be added." - assert len(configs) == len(scores), "Mismatched number of configs and scores." - if context is not None: - assert len(configs) == len(context), "Mismatched number of configs and context." - assert configs.shape[1] == len( + assert len(observation.config) == len( self.parameter_space.values() ), "Mismatched configuration shape." - self._observations.append((configs, scores, context)) - self._has_context = context is not None + self._has_context = observation.context is not None + self._observations.append(observation) + + transformed_observation = deepcopy(observation) # Needed to support named tuples if self._space_adapter: - configs = self._space_adapter.inverse_transform(configs) - assert configs.shape[1] == len( + transformed_observation = Observation( + config=self._space_adapter.inverse_transform(transformed_observation.config), + score=transformed_observation.score, + context=transformed_observation.context, + metadata=transformed_observation.metadata, + ) + assert len(transformed_observation.config) == len( self.optimizer_parameter_space.values() ), "Mismatched configuration shape after inverse transform." - return self._register(configs=configs, scores=scores, context=context) + return transformed_observation @abstractmethod def _register( self, - *, - configs: pd.DataFrame, - scores: pd.DataFrame, - context: Optional[pd.DataFrame] = None, - metadata: Optional[pd.DataFrame] = None, + observations: Observations, ) -> None: """ Registers the given configs and scores. Parameters ---------- - configs : pandas.DataFrame - Dataframe of configs / parameters. The columns are parameter names and - the rows are the configs. - scores : pandas.DataFrame - Scores from running the configs. The index is the same as the index of the configs. - - context : pandas.DataFrame - Not Yet Implemented. + observations: Observations + The set of observations to register. """ pass # pylint: disable=unnecessary-pass # pragma: no cover def suggest( self, *, - context: Optional[pd.DataFrame] = None, + context: Optional[pd.Series] = None, defaults: bool = False, - ) -> Tuple[pd.DataFrame, Optional[pd.DataFrame]]: + ) -> Suggestion: """ Wrapper method, which employs the space adapter (if any), after suggesting a new configuration. Parameters ---------- - context : pandas.DataFrame + context : pandas.Series Not Yet Implemented. defaults : bool Whether or not to return the default config instead of an optimizer guided one. @@ -176,114 +185,89 @@ def suggest( Returns ------- - configuration : pandas.DataFrame - Pandas dataframe with a single row. Column names are the parameter names. - - metadata : Optional[pandas.DataFrame] - The metadata associated with the given configuration used for evaluations. - Backend optimizer specific. + suggestion: Suggestion + The suggested point to evaluate. """ if defaults: - configuration = config_to_dataframe(self.parameter_space.get_default_configuration()) - metadata = None + configuration = config_to_series(self.parameter_space.get_default_configuration()) if self.space_adapter is not None: configuration = self.space_adapter.inverse_transform(configuration) + suggestion = Suggestion(config=configuration, context=context, metadata=None) else: - configuration, metadata = self._suggest(context=context) - assert len(configuration) == 1, "Suggest must return a single configuration." - assert set(configuration.columns).issubset(set(self.optimizer_parameter_space)), ( + suggestion = self._suggest(context=context) + assert set(suggestion.config.index).issubset(set(self.optimizer_parameter_space)), ( "Optimizer suggested a configuration that does " "not match the expected parameter space." ) if self._space_adapter: - configuration = self._space_adapter.transform(configuration) - assert set(configuration.columns).issubset(set(self.parameter_space)), ( + suggestion = Suggestion( + config=self._space_adapter.transform(suggestion.config), + context=suggestion.context, + metadata=suggestion.metadata, + ) + assert set(suggestion.config.index).issubset(set(self.parameter_space)), ( "Space adapter produced a configuration that does " "not match the expected parameter space." ) - return configuration, metadata + return suggestion @abstractmethod def _suggest( self, *, - context: Optional[pd.DataFrame] = None, - ) -> Tuple[pd.DataFrame, Optional[pd.DataFrame]]: + context: Optional[pd.Series] = None, + ) -> Suggestion: """ Suggests a new configuration. Parameters ---------- - context : pandas.DataFrame + context : pandas.Series Not Yet Implemented. Returns ------- - configuration : pandas.DataFrame - Pandas dataframe with a single row. Column names are the parameter names. - - metadata : Optional[pandas.DataFrame] - The metadata associated with the given configuration used for evaluations. - Backend optimizer specific. + suggestion: Suggestion + The suggestion to evaluate. """ pass # pylint: disable=unnecessary-pass # pragma: no cover @abstractmethod - def register_pending( - self, - *, - configs: pd.DataFrame, - context: Optional[pd.DataFrame] = None, - metadata: Optional[pd.DataFrame] = None, - ) -> None: + def register_pending(self, pending: Suggestion) -> None: """ - Registers the given configs as "pending". That is it say, it has been suggested - by the optimizer, and an experiment trial has been started. This can be useful - for executing multiple trials in parallel, retry logic, etc. + Registers the given suggestion as "pending". That is it say, it has been + suggested by the optimizer, and an experiment trial has been started. This can + be useful for executing multiple trials in parallel, retry logic, etc. Parameters ---------- - configs : pandas.DataFrame - Dataframe of configs / parameters. The columns are parameter names and - the rows are the configs. - context : pandas.DataFrame - Not Yet Implemented. - metadata : Optional[pandas.DataFrame] - Metadata returned by the backend optimizer's suggest method. + pending: Suggestion + The pending suggestion to register. """ pass # pylint: disable=unnecessary-pass # pragma: no cover - def get_observations(self) -> Tuple[pd.DataFrame, pd.DataFrame, Optional[pd.DataFrame]]: + def get_observations(self) -> Observations: """ Returns the observations as a triplet of DataFrames (config, score, context). Returns ------- - observations : Tuple[pandas.DataFrame, pandas.DataFrame, Optional[pandas.DataFrame]] - A triplet of (config, score, context) DataFrames of observations. + observations : Observations + All the observations registered so far. """ if len(self._observations) == 0: raise ValueError("No observations registered yet.") - configs = pd.concat([config for config, _, _ in self._observations]).reset_index(drop=True) - scores = pd.concat([score for _, score, _ in self._observations]).reset_index(drop=True) - contexts = pd.concat( - [ - pd.DataFrame() if context is None else context - for _, _, context in self._observations - ] - ).reset_index(drop=True) - return (configs, scores, contexts if len(contexts.columns) > 0 else None) + return self._observations def get_best_observations( self, - *, n_max: int = 1, - ) -> Tuple[pd.DataFrame, pd.DataFrame, Optional[pd.DataFrame]]: + ) -> Observations: """ - Get the N best observations so far as a triplet of DataFrames (config, score, - context). Default is N=1. The columns are ordered in ASCENDING order of the - optimization targets. The function uses `pandas.DataFrame.nsmallest(..., - keep="first")` method under the hood. + Get the N best observations so far as a filtered version of Observations. + Default is N=1. The columns are ordered in ASCENDING order of the optimization + targets. The function uses `pandas.DataFrame.nsmallest(..., keep="first")` + method under the hood. Parameters ---------- @@ -292,14 +276,19 @@ def get_best_observations( Returns ------- - observations : Tuple[pandas.DataFrame, pandas.DataFrame, Optional[pandas.DataFrame]] - A triplet of best (config, score, context) DataFrames of best observations. + observations : Observations + A filtered version of Observations with the best N observations. """ - if len(self._observations) == 0: + observations = self.get_observations() + if len(observations) == 0: raise ValueError("No observations registered yet.") - (configs, scores, contexts) = self.get_observations() - idx = scores.nsmallest(n_max, columns=self._optimization_targets, keep="first").index - return (configs.loc[idx], scores.loc[idx], None if contexts is None else contexts.loc[idx]) + + idx = observations.scores.nsmallest( + n_max, + columns=self._optimization_targets, + keep="first", + ).index + return observations.filter_by_index(idx) def cleanup(self) -> None: """ @@ -309,7 +298,7 @@ def cleanup(self) -> None: cleanup. """ - def _from_1hot(self, *, config: npt.NDArray) -> pd.DataFrame: + def _from_1hot(self, config: npt.NDArray) -> pd.DataFrame: """Convert numpy array from one-hot encoding to a DataFrame with categoricals and ints in proper columns. """ @@ -331,7 +320,7 @@ def _from_1hot(self, *, config: npt.NDArray) -> pd.DataFrame: j += 1 return pd.DataFrame(df_dict) - def _to_1hot(self, *, config: Union[pd.DataFrame, pd.Series]) -> npt.NDArray: + def _to_1hot(self, config: Union[pd.DataFrame, pd.Series]) -> npt.NDArray: """Convert pandas DataFrame to one-hot-encoded numpy array.""" n_cols = 0 n_rows = config.shape[0] if config.ndim > 1 else 1 diff --git a/mlos_core/mlos_core/optimizers/random_optimizer.py b/mlos_core/mlos_core/optimizers/random_optimizer.py index a086c9d8042..a6a5d603b88 100644 --- a/mlos_core/mlos_core/optimizers/random_optimizer.py +++ b/mlos_core/mlos_core/optimizers/random_optimizer.py @@ -4,11 +4,12 @@ # """RandomOptimizer class.""" -from typing import Optional, Tuple +from typing import Optional from warnings import warn import pandas as pd +from mlos_core.data_classes import Observations, Suggestion from mlos_core.optimizers.optimizer import BaseOptimizer @@ -21,43 +22,37 @@ class RandomOptimizer(BaseOptimizer): def _register( self, - *, - configs: pd.DataFrame, - scores: pd.DataFrame, - context: Optional[pd.DataFrame] = None, - metadata: Optional[pd.DataFrame] = None, + observations: Observations, ) -> None: """ - Registers the given configs and scores. + Registers the given config/score pairs. + Notes + ----- Doesn't do anything on the RandomOptimizer except storing configs for logging. Parameters ---------- - configs : pandas.DataFrame - Dataframe of configs / parameters. The columns are parameter names and - the rows are the configs. - - scores : pandas.DataFrame - Scores from running the configs. The index is the same as the index of the configs. - - context : None - Not Yet Implemented. - - metadata : None - Not Yet Implemented. + observations : Observations + The observations to register. """ - if context is not None: - warn(f"Not Implemented: Ignoring context {list(context.columns)}", UserWarning) - if metadata is not None: - warn(f"Not Implemented: Ignoring context {list(metadata.columns)}", UserWarning) + if observations.contexts is not None: + warn( + f"Not Implemented: Ignoring context {list(observations.contexts.index)}", + UserWarning, + ) + if observations.metadata is not None: + warn( + f"Not Implemented: Ignoring context {list(observations.metadata.index)}", + UserWarning, + ) # should we pop them from self.pending_observations? def _suggest( self, *, - context: Optional[pd.DataFrame] = None, - ) -> Tuple[pd.DataFrame, Optional[pd.DataFrame]]: + context: Optional[pd.Series] = None, + ) -> Suggestion: """ Suggests a new configuration. @@ -70,26 +65,18 @@ def _suggest( Returns ------- - configuration : pd.DataFrame - Pandas dataframe with a single row. Column names are the parameter names. - - metadata : None - Not implemented. + suggestion: Suggestion + The suggestion to evaluate. """ if context is not None: # not sure how that works here? - warn(f"Not Implemented: Ignoring context {list(context.columns)}", UserWarning) - return ( - pd.DataFrame(dict(self.optimizer_parameter_space.sample_configuration()), index=[0]), - None, + warn(f"Not Implemented: Ignoring context {list(context.index)}", UserWarning) + return Suggestion( + config=pd.Series(self.optimizer_parameter_space.sample_configuration(), dtype=object), + context=context, + metadata=None, ) - def register_pending( - self, - *, - configs: pd.DataFrame, - context: Optional[pd.DataFrame] = None, - metadata: Optional[pd.DataFrame] = None, - ) -> None: + def register_pending(self, pending: Suggestion) -> None: raise NotImplementedError() # self._pending_observations.append((configs, context)) diff --git a/mlos_core/mlos_core/spaces/adapters/adapter.py b/mlos_core/mlos_core/spaces/adapters/adapter.py index b4b53b73a08..d4631d0f57a 100644 --- a/mlos_core/mlos_core/spaces/adapters/adapter.py +++ b/mlos_core/mlos_core/spaces/adapters/adapter.py @@ -55,7 +55,7 @@ def target_parameter_space(self) -> ConfigSpace.ConfigurationSpace: pass # pylint: disable=unnecessary-pass # pragma: no cover @abstractmethod - def transform(self, configuration: pd.DataFrame) -> pd.DataFrame: + def transform(self, configuration: pd.Series) -> pd.Series: """ Translates a configuration, which belongs to the target parameter space, to the original parameter space. This method is called by the @@ -64,20 +64,20 @@ def transform(self, configuration: pd.DataFrame) -> pd.DataFrame: Parameters ---------- - configuration : pandas.DataFrame - Pandas dataframe with a single row. Column names are the parameter names + configuration : pandas.Series + Pandas series. Column names are the parameter names of the target parameter space. Returns ------- - configuration : pandas.DataFrame - Pandas dataframe with a single row, containing the translated configuration. + configuration : pandas.Series + Pandas series, containing the translated configuration. Column names are the parameter names of the original parameter space. """ pass # pylint: disable=unnecessary-pass # pragma: no cover @abstractmethod - def inverse_transform(self, configurations: pd.DataFrame) -> pd.DataFrame: + def inverse_transform(self, configuration: pd.Series) -> pd.Series: """ Translates a configuration, which belongs to the original parameter space, to the target parameter space. This method is called by the `register` method of @@ -87,16 +87,16 @@ def inverse_transform(self, configurations: pd.DataFrame) -> pd.DataFrame: Parameters ---------- - configurations : pandas.DataFrame - Dataframe of configurations / parameters, which belong to the original parameter space. - The columns are the parameter names the original parameter space and the + configuration : pandas.Series + A Series of configuration parameters, which belong to the original parameter space. + The indices are the parameter names the original parameter space and the rows are the configurations. Returns ------- - configurations : pandas.DataFrame - Dataframe of the translated configurations / parameters. - The columns are the parameter names of the target parameter space and + configuration : pandas.Series + Series of the translated configurations / parameters. + The indices are the parameter names of the target parameter space and the rows are the configurations. """ pass # pylint: disable=unnecessary-pass # pragma: no cover diff --git a/mlos_core/mlos_core/spaces/adapters/identity_adapter.py b/mlos_core/mlos_core/spaces/adapters/identity_adapter.py index 1e552110a20..d56aad0f8f4 100644 --- a/mlos_core/mlos_core/spaces/adapters/identity_adapter.py +++ b/mlos_core/mlos_core/spaces/adapters/identity_adapter.py @@ -24,8 +24,8 @@ class IdentityAdapter(BaseSpaceAdapter): def target_parameter_space(self) -> ConfigSpace.ConfigurationSpace: return self._orig_parameter_space - def transform(self, configuration: pd.DataFrame) -> pd.DataFrame: + def transform(self, configuration: pd.Series) -> pd.Series: return configuration - def inverse_transform(self, configurations: pd.DataFrame) -> pd.DataFrame: - return configurations + def inverse_transform(self, configuration: pd.Series) -> pd.Series: + return configuration diff --git a/mlos_core/mlos_core/spaces/adapters/llamatune.py b/mlos_core/mlos_core/spaces/adapters/llamatune.py index 625dd886d08..16092176641 100644 --- a/mlos_core/mlos_core/spaces/adapters/llamatune.py +++ b/mlos_core/mlos_core/spaces/adapters/llamatune.py @@ -26,7 +26,7 @@ from sklearn.preprocessing import MinMaxScaler from mlos_core.spaces.adapters.adapter import BaseSpaceAdapter -from mlos_core.util import drop_nulls, normalize_config +from mlos_core.util import normalize_config class LlamaTuneAdapter(BaseSpaceAdapter): # pylint: disable=too-many-instance-attributes @@ -89,7 +89,7 @@ def __init__( # pylint: disable=too-many-arguments # Initialize config values scaler: from (-1, 1) to (0, 1) range config_scaler = MinMaxScaler(feature_range=(0, 1)) ones_vector = np.ones(len(list(self.orig_parameter_space.values()))) - config_scaler.fit([-ones_vector, ones_vector]) + config_scaler.fit(np.array([-ones_vector, ones_vector])) self._config_scaler = config_scaler # Generate random mapping from low-dimensional space to original config space @@ -107,43 +107,36 @@ def target_parameter_space(self) -> ConfigSpace.ConfigurationSpace: """Get the parameter space, which is explored by the underlying optimizer.""" return self._target_config_space - def inverse_transform(self, configurations: pd.DataFrame) -> pd.DataFrame: - target_configurations = [] - for _, config in configurations.astype("O").iterrows(): - configuration = ConfigSpace.Configuration( - self.orig_parameter_space, - values=drop_nulls(config.to_dict()), - ) + def inverse_transform(self, configuration: pd.Series) -> pd.Series: + config = ConfigSpace.Configuration( + self.orig_parameter_space, + values=configuration.dropna().to_dict(), + ) - target_config = self._suggested_configs.get(configuration, None) - # NOTE: HeSBO is a non-linear projection method, and does not inherently - # support inverse projection - # To (partly) support this operation, we keep track of the suggested - # low-dim point(s) along with the respective high-dim point; this way we - # can retrieve the low-dim point, from its high-dim counterpart. - if target_config is None: - # Inherently it is not supported to register points, which were not - # suggested by the optimizer. - if configuration == self.orig_parameter_space.get_default_configuration(): - # Default configuration should always be registerable. - pass - elif not self._use_approximate_reverse_mapping: - raise ValueError( - f"{repr(configuration)}\n" - "The above configuration was not suggested by the optimizer. " - "Approximate reverse mapping is currently disabled; " - "thus *only* configurations suggested " - "previously by the optimizer can be registered." - ) - # else ... - target_config = self._try_inverse_transform_config(configuration) + target_config = self._suggested_configs.get(config, None) + # NOTE: HeSBO is a non-linear projection method, and does not inherently + # support inverse projection + # To (partly) support this operation, we keep track of the suggested + # low-dim point(s) along with the respective high-dim point; this way we + # can retrieve the low-dim point, from its high-dim counterpart. + if target_config is None: + # Inherently it is not supported to register points, which were not + # suggested by the optimizer. + if config == self.orig_parameter_space.get_default_configuration(): + # Default configuration should always be registerable. + pass + elif not self._use_approximate_reverse_mapping: + raise ValueError( + f"{repr(config)}\n" + "The above configuration was not suggested by the optimizer. " + "Approximate reverse mapping is currently disabled; " + "thus *only* configurations suggested " + "previously by the optimizer can be registered." + ) - target_configurations.append(target_config) + target_config = self._try_inverse_transform_config(config) - return pd.DataFrame( - target_configurations, - columns=list(self.target_parameter_space.keys()), - ) + return pd.Series(target_config, index=list(self.target_parameter_space.keys())) def _try_inverse_transform_config( self, @@ -177,7 +170,7 @@ def _try_inverse_transform_config( config_vector = np.nan_to_num(config.get_array(), nan=0.0) # Perform approximate reverse mapping # NOTE: applying special value biasing is not possible - vector: npt.NDArray = self._config_scaler.inverse_transform([config_vector])[0] + vector: npt.NDArray = self._config_scaler.inverse_transform(np.array([config_vector]))[0] target_config_vector: npt.NDArray = self._pinv_matrix.dot(vector) # Clip values to to [-1, 1] range of the low dimensional space. for idx, value in enumerate(target_config_vector): @@ -185,7 +178,9 @@ def _try_inverse_transform_config( if self._q_scaler is not None: # If the max_unique_values_per_param is set, we need to scale # the low dimension space back to the discretized space as well. - target_config_vector = self._q_scaler.inverse_transform([target_config_vector])[0] + target_config_vector = self._q_scaler.inverse_transform( + np.array([target_config_vector]) + )[0] assert isinstance(target_config_vector, np.ndarray) # Clip values to [1, max_value] range (floating point errors may occur). for idx, value in enumerate(target_config_vector): @@ -236,22 +231,16 @@ def _try_inverse_transform_config( self.target_parameter_space, values=target_config, ).check_valid_configuration() - except ConfigSpace.exceptions.IllegalValueError as e: + except ConfigSpace.exceptions.IllegalValueError as err: raise ValueError( f"Invalid configuration {target_config} generated by " - f"inverse mapping of {config}:\n{e}" - ) from e + f"inverse mapping of {config}:\n{err}" + ) from err return target_config - def transform(self, configuration: pd.DataFrame) -> pd.DataFrame: - if len(configuration) != 1: - raise ValueError( - "Configuration dataframe must contain exactly 1 row. " - f"Found {len(configuration)} rows." - ) - - target_values_dict = configuration.iloc[0].to_dict() + def transform(self, configuration: pd.Series) -> pd.Series: + target_values_dict = configuration.to_dict() target_configuration = ConfigSpace.Configuration( self.target_parameter_space, values=target_values_dict, @@ -266,18 +255,19 @@ def transform(self, configuration: pd.DataFrame) -> pd.DataFrame: self.orig_parameter_space, values=orig_configuration, ).check_valid_configuration() - except ConfigSpace.exceptions.IllegalValueError as e: + except ConfigSpace.exceptions.IllegalValueError as err: raise ValueError( f"Invalid configuration {orig_configuration} generated by " - f"transformation of {target_configuration}:\n{e}" - ) from e + f"transformation of {target_configuration}:\n{err}" + ) from err # Add to inverse dictionary -- needed for registering the performance later self._suggested_configs[orig_configuration] = target_configuration - return pd.DataFrame( - [list(orig_configuration.values())], columns=list(orig_configuration.keys()) + ret: pd.Series = pd.Series( + list(orig_configuration.values()), index=list(orig_configuration.keys()) ) + return ret def _construct_low_dim_space( self, @@ -327,7 +317,7 @@ def _construct_low_dim_space( q_scaler = MinMaxScaler(feature_range=(-1, 1)) ones_vector = np.ones(num_low_dims) max_value_vector = ones_vector * max_unique_values_per_param - q_scaler.fit([ones_vector, max_value_vector]) + q_scaler.fit(np.array([ones_vector, max_value_vector])) self._q_scaler = q_scaler @@ -359,7 +349,7 @@ def _transform(self, configuration: dict) -> dict: if self._q_scaler is not None: # Scale parameter values from [1, max_value] to [-1, 1] - low_dim_config_values = self._q_scaler.transform([low_dim_config_values])[0] + low_dim_config_values = self._q_scaler.transform(np.array([low_dim_config_values]))[0] # Project low-dim point to original parameter space original_config_values = [ @@ -367,7 +357,9 @@ def _transform(self, configuration: dict) -> dict: for idx in range(len(original_parameters)) ] # Scale parameter values to [0, 1] - original_config_values = self._config_scaler.transform([original_config_values])[0] + original_config_values = self._config_scaler.transform(np.array([original_config_values]))[ + 0 + ] original_config = {} for param, norm_value in zip(original_parameters, original_config_values): @@ -574,7 +566,8 @@ def _try_generate_approx_inverse_mapping(self) -> None: # Compute pseudo-inverse matrix try: - self._pinv_matrix = pinv(proj_matrix) + inv_matrix: npt.NDArray = pinv(proj_matrix) + self._pinv_matrix = inv_matrix except LinAlgError as err: raise RuntimeError( f"Unable to generate reverse mapping using pseudo-inverse matrix: {repr(err)}" diff --git a/mlos_core/mlos_core/tests/optimizers/bayesian_optimizers_test.py b/mlos_core/mlos_core/tests/optimizers/bayesian_optimizers_test.py index 65f0d9ab927..efdd86f48d0 100644 --- a/mlos_core/mlos_core/tests/optimizers/bayesian_optimizers_test.py +++ b/mlos_core/mlos_core/tests/optimizers/bayesian_optimizers_test.py @@ -36,16 +36,17 @@ def test_context_not_implemented_warning( optimization_targets=["score"], **kwargs, ) - suggestion, _metadata = optimizer.suggest() - scores = pd.DataFrame({"score": [1]}) - context = pd.DataFrame([["something"]]) + suggestion = optimizer.suggest() + scores = pd.Series({"score": [1]}) + context = pd.Series([["something"]]) + suggestion._context = context # pylint: disable=protected-access with pytest.raises(UserWarning): - optimizer.register(configs=suggestion, scores=scores, context=context) + optimizer.register(observations=suggestion.complete(scores)) with pytest.raises(UserWarning): optimizer.suggest(context=context) if isinstance(optimizer, BaseBayesianOptimizer): with pytest.raises(UserWarning): - optimizer.surrogate_predict(configs=suggestion, context=context) + optimizer.surrogate_predict(suggestion=suggestion) diff --git a/mlos_core/mlos_core/tests/optimizers/data_class_test.py b/mlos_core/mlos_core/tests/optimizers/data_class_test.py new file mode 100644 index 00000000000..6c250513fee --- /dev/null +++ b/mlos_core/mlos_core/tests/optimizers/data_class_test.py @@ -0,0 +1,295 @@ +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +"""Tests for Observation Data Class.""" + + +import pandas as pd +import pytest + +from mlos_core.data_classes import Observation, Observations, Suggestion +from mlos_core.util import compare_optional_series + +# pylint: disable=redefined-outer-name + + +@pytest.fixture +def config() -> pd.Series: + """Toy configuration used to build various data classes.""" + return pd.Series( + { + "y": "b", + "x": 0.4, + "z": 3, + } + ) + + +@pytest.fixture +def score() -> pd.Series: + """Toy score used for tests.""" + return pd.Series( + { + "main_score": 0.1, + "other_score": 0.2, + } + ) + + +@pytest.fixture +def score2() -> pd.Series: + """Toy score used for tests.""" + return pd.Series( + { + "main_score": 0.3, + "other_score": 0.1, + } + ) + + +@pytest.fixture +def metadata() -> pd.Series: + """Toy metadata used for tests.""" + return pd.Series( + { + "metadata": "test", + } + ) + + +@pytest.fixture +def context() -> pd.Series: + """Toy context used for tests.""" + return pd.Series( + { + "context": "test", + } + ) + + +@pytest.fixture +def config2() -> pd.Series: + """An alternative toy configuration used to build various data classes.""" + return pd.Series( + { + "y": "c", + "x": 0.7, + "z": 1, + } + ) + + +@pytest.fixture +def observation_with_context( + config: pd.Series, + score: pd.Series, + metadata: pd.Series, + context: pd.Series, +) -> Observation: + """Toy observation used for tests.""" + return Observation( + config=config, + score=score, + metadata=metadata, + context=context, + ) + + +@pytest.fixture +def observation_without_context(config2: pd.Series, score2: pd.Series) -> Observation: + """Toy observation used for tests.""" + return Observation( + config=config2, + score=score2, + ) + + +@pytest.fixture +def observations_with_context(observation_with_context: Observation) -> Observations: + """Toy observation used for tests.""" + return Observations( + observations=[observation_with_context, observation_with_context, observation_with_context] + ) + + +@pytest.fixture +def observations_without_context(observation_without_context: Observation) -> Observations: + """Toy observation used for tests.""" + return Observations( + observations=[ + observation_without_context, + observation_without_context, + observation_without_context, + ] + ) + + +@pytest.fixture +def suggestion_with_context( + config: pd.Series, + metadata: pd.Series, + context: pd.Series, +) -> Suggestion: + """Toy suggestion used for tests.""" + return Suggestion( + config=config, + metadata=metadata, + context=context, + ) + + +@pytest.fixture +def suggestion_without_context(config2: pd.Series) -> Suggestion: + """Toy suggestion used for tests.""" + return Suggestion( + config=config2, + ) + + +def test_observation_to_suggestion( + observation_with_context: Observation, + observation_without_context: Observation, +) -> None: + """Toy problem to test one-hot encoding of dataframe.""" + for observation in (observation_with_context, observation_without_context): + suggestion = observation.to_suggestion() + assert compare_optional_series(suggestion.config, observation.config) + assert compare_optional_series(suggestion.metadata, observation.metadata) + assert compare_optional_series(suggestion.context, observation.context) + + +def test_observation_equality_operators( + observation_with_context: Observation, + observation_without_context: Observation, +) -> None: + """Test equality operators.""" + # pylint: disable=comparison-with-itself + assert observation_with_context == observation_with_context + assert observation_with_context != observation_without_context + assert observation_without_context == observation_without_context + + +def test_observations_init_components( + config: pd.Series, + score: pd.Series, + metadata: pd.Series, + context: pd.Series, +) -> None: + """Test Observations class.""" + Observations( + configs=pd.concat([config.to_frame().T, config.to_frame().T]), + scores=pd.concat([score.to_frame().T, score.to_frame().T]), + contexts=pd.concat([context.to_frame().T, context.to_frame().T]), + metadata=pd.concat([metadata.to_frame().T, metadata.to_frame().T]), + ) + + +def test_observations_init_observations(observation_with_context: Observation) -> None: + """Test Observations class.""" + Observations( + observations=[observation_with_context, observation_with_context], + ) + + +def test_observations_init_components_fails( + config: pd.Series, + score: pd.Series, + metadata: pd.Series, + context: pd.Series, +) -> None: + """Test Observations class.""" + with pytest.raises(AssertionError): + Observations( + configs=pd.concat([config.to_frame().T]), + scores=pd.concat([score.to_frame().T, score.to_frame().T]), + contexts=pd.concat([context.to_frame().T, context.to_frame().T]), + metadata=pd.concat([metadata.to_frame().T, metadata.to_frame().T]), + ) + with pytest.raises(AssertionError): + Observations( + configs=pd.concat([config.to_frame().T, config.to_frame().T]), + scores=pd.concat([score.to_frame().T]), + contexts=pd.concat([context.to_frame().T, context.to_frame().T]), + metadata=pd.concat([metadata.to_frame().T, metadata.to_frame().T]), + ) + with pytest.raises(AssertionError): + Observations( + configs=pd.concat([config.to_frame().T, config.to_frame().T]), + scores=pd.concat([score.to_frame().T, score.to_frame().T]), + contexts=pd.concat([context.to_frame().T, context.to_frame().T]), + metadata=pd.concat([metadata.to_frame().T]), + ) + with pytest.raises(AssertionError): + Observations( + configs=pd.concat([config.to_frame().T, config.to_frame().T]), + scores=pd.concat([score.to_frame().T, score.to_frame().T]), + contexts=pd.concat([context.to_frame().T]), + metadata=pd.concat([metadata.to_frame().T, metadata.to_frame().T]), + ) + + +def test_observations_append(observation_with_context: Observation) -> None: + """Test Observations class.""" + observations = Observations() + observations.append(observation_with_context) + observations.append(observation_with_context) + assert len(observations) == 2 + + +def test_observations_append_fails( + observation_with_context: Observation, + observation_without_context: Observation, +) -> None: + """Test Observations class.""" + observations = Observations() + observations.append(observation_with_context) + with pytest.raises(AssertionError): + observations.append(observation_without_context) + + +def test_observations_filter_by_index(observations_with_context: Observations) -> None: + """Test Observations class.""" + assert ( + len( + observations_with_context.filter_by_index(observations_with_context.configs.index[[0]]) + ) + == 1 + ) + + +def test_observations_to_list(observations_with_context: Observations) -> None: + """Test Observations class.""" + assert len(list(observations_with_context)) == 3 + assert all(isinstance(observation, Observation) for observation in observations_with_context) + + +def test_observations_equality_test( + observations_with_context: Observations, + observations_without_context: Observations, +) -> None: + """Test Equality of observations.""" + # pylint: disable=comparison-with-itself + assert observations_with_context == observations_with_context + assert observations_with_context != observations_without_context + assert observations_without_context == observations_without_context + + +def test_suggestion_equality_test( + suggestion_with_context: Suggestion, + suggestion_without_context: Suggestion, +) -> None: + """Test Equality of suggestions.""" + # pylint: disable=comparison-with-itself + assert suggestion_with_context == suggestion_with_context + assert suggestion_with_context != suggestion_without_context + assert suggestion_without_context == suggestion_without_context + + +def test_complete_suggestion( + suggestion_with_context: Suggestion, + score: pd.Series, + observation_with_context: Observation, +) -> None: + """Test ability to complete suggestions.""" + assert suggestion_with_context.complete(score) == observation_with_context diff --git a/mlos_core/mlos_core/tests/optimizers/optimizer_multiobj_test.py b/mlos_core/mlos_core/tests/optimizers/optimizer_multiobj_test.py index 4c7edd74938..11077aca853 100644 --- a/mlos_core/mlos_core/tests/optimizers/optimizer_multiobj_test.py +++ b/mlos_core/mlos_core/tests/optimizers/optimizer_multiobj_test.py @@ -8,10 +8,10 @@ from typing import List, Optional, Type import ConfigSpace as CS -import numpy as np import pandas as pd import pytest +from mlos_core.data_classes import Observations, Suggestion from mlos_core.optimizers import BaseOptimizer, OptimizerType from mlos_core.tests import SEED @@ -65,14 +65,15 @@ def test_multi_target_opt( # pylint: disable=too-many-locals max_iterations = 10 - def objective(point: pd.DataFrame) -> pd.DataFrame: + def objective(point: pd.Series) -> pd.Series: # mix of hyperparameters, optimal is to select the highest possible - return pd.DataFrame( + ret: pd.Series = pd.Series( { "main_score": point.x + point.y, "other_score": point.x**2 + point.y**2, } ) + return ret input_space = CS.ConfigurationSpace(seed=SEED) # add a mix of numeric datatypes @@ -93,39 +94,41 @@ def objective(point: pd.DataFrame) -> pd.DataFrame: optimizer.get_observations() for _ in range(max_iterations): - suggestion, metadata = optimizer.suggest() - assert isinstance(suggestion, pd.DataFrame) - assert metadata is None or isinstance(metadata, pd.DataFrame) - assert set(suggestion.columns) == {"x", "y"} + suggestion = optimizer.suggest() + assert isinstance(suggestion, Suggestion) + assert isinstance(suggestion.config, pd.Series) + assert suggestion.metadata is None or isinstance(suggestion.metadata, pd.Series) + assert set(suggestion.config.index) == {"x", "y"} # Check suggestion values are the expected dtype - assert isinstance(suggestion.x.iloc[0], np.integer) - assert isinstance(suggestion.y.iloc[0], np.floating) + assert isinstance(suggestion.config["x"], int) + assert isinstance(suggestion.config["y"], float) # Check that suggestion is in the space - test_configuration = CS.Configuration( - optimizer.parameter_space, suggestion.astype("O").iloc[0].to_dict() - ) + config_dict: dict = suggestion.config.to_dict() + test_configuration = CS.Configuration(optimizer.parameter_space, config_dict) # Raises an error if outside of configuration space test_configuration.check_valid_configuration() # Test registering the suggested configuration with a score. - observation = objective(suggestion) - assert isinstance(observation, pd.DataFrame) - assert set(observation.columns) == {"main_score", "other_score"} - optimizer.register(configs=suggestion, scores=observation) - - (best_config, best_score, best_context) = optimizer.get_best_observations() - assert isinstance(best_config, pd.DataFrame) - assert isinstance(best_score, pd.DataFrame) - assert best_context is None - assert set(best_config.columns) == {"x", "y"} - assert set(best_score.columns) == {"main_score", "other_score"} - assert best_config.shape == (1, 2) - assert best_score.shape == (1, 2) - - (all_configs, all_scores, all_contexts) = optimizer.get_observations() - assert isinstance(all_configs, pd.DataFrame) - assert isinstance(all_scores, pd.DataFrame) - assert all_contexts is None - assert set(all_configs.columns) == {"x", "y"} - assert set(all_scores.columns) == {"main_score", "other_score"} - assert all_configs.shape == (max_iterations, 2) - assert all_scores.shape == (max_iterations, 2) + observation = objective(suggestion.config) + assert isinstance(observation, pd.Series) + assert set(observation.index) == {"main_score", "other_score"} + optimizer.register(observations=suggestion.complete(observation)) + + best_observations = optimizer.get_best_observations() + assert isinstance(best_observations, Observations) + assert isinstance(best_observations.configs, pd.DataFrame) + assert isinstance(best_observations.scores, pd.DataFrame) + assert best_observations.contexts is None + assert set(best_observations.configs.columns) == {"x", "y"} + assert set(best_observations.scores.columns) == {"main_score", "other_score"} + assert best_observations.configs.shape == (1, 2) + assert best_observations.scores.shape == (1, 2) + + all_observations = optimizer.get_observations() + assert isinstance(all_observations, Observations) + assert isinstance(all_observations.configs, pd.DataFrame) + assert isinstance(all_observations.scores, pd.DataFrame) + assert all_observations.contexts is None + assert set(all_observations.configs.columns) == {"x", "y"} + assert set(all_observations.scores.columns) == {"main_score", "other_score"} + assert all_observations.configs.shape == (max_iterations, 2) + assert all_observations.scores.shape == (max_iterations, 2) diff --git a/mlos_core/mlos_core/tests/optimizers/optimizer_test.py b/mlos_core/mlos_core/tests/optimizers/optimizer_test.py index 776d01c38f2..4a909fee21d 100644 --- a/mlos_core/mlos_core/tests/optimizers/optimizer_test.py +++ b/mlos_core/mlos_core/tests/optimizers/optimizer_test.py @@ -6,13 +6,14 @@ import logging from copy import deepcopy -from typing import List, Optional, Type +from typing import Any, List, Optional, Type import ConfigSpace as CS import numpy as np import pandas as pd import pytest +from mlos_core.data_classes import Observations, Suggestion from mlos_core.optimizers import ( BaseOptimizer, ConcreteOptimizer, @@ -53,7 +54,7 @@ def test_create_optimizer_and_suggest( assert optimizer.parameter_space is not None - suggestion, metadata = optimizer.suggest() + suggestion = optimizer.suggest() assert suggestion is not None myrepr = repr(optimizer) @@ -61,7 +62,7 @@ def test_create_optimizer_and_suggest( # pending not implemented with pytest.raises(NotImplementedError): - optimizer.register_pending(configs=suggestion, metadata=metadata) + optimizer.register_pending(pending=suggestion) @pytest.mark.parametrize( @@ -87,8 +88,11 @@ def test_basic_interface_toy_problem( # number of max iterations. kwargs["max_trials"] = max_iterations * 2 - def objective(x: pd.Series) -> pd.DataFrame: - return pd.DataFrame({"score": (6 * x - 2) ** 2 * np.sin(12 * x - 4)}) + def objective(inp: float) -> pd.Series: + series: pd.Series = pd.Series( + {"score": (6 * inp - 2) ** 2 * np.sin(12 * inp - 4)} + ) # needed for type hinting + return series # Emukit doesn't allow specifying a random state, so we set the global seed. np.random.seed(SEED) @@ -105,45 +109,57 @@ def objective(x: pd.Series) -> pd.DataFrame: optimizer.get_observations() for _ in range(max_iterations): - suggestion, metadata = optimizer.suggest() - assert isinstance(suggestion, pd.DataFrame) - assert metadata is None or isinstance(metadata, pd.DataFrame) - assert set(suggestion.columns) == {"x", "y", "z"} + suggestion = optimizer.suggest() + assert isinstance(suggestion, Suggestion) + assert isinstance(suggestion.config, pd.Series) + assert suggestion.metadata is None or isinstance(suggestion.metadata, pd.Series) + assert set(suggestion.config.index) == {"x", "y", "z"} # check that suggestion is in the space - configuration = CS.Configuration(optimizer.parameter_space, suggestion.iloc[0].to_dict()) + dict_config: dict = suggestion.config.to_dict() + configuration = CS.Configuration(optimizer.parameter_space, dict_config) # Raises an error if outside of configuration space configuration.check_valid_configuration() - observation = objective(suggestion["x"]) - assert isinstance(observation, pd.DataFrame) - optimizer.register(configs=suggestion, scores=observation, metadata=metadata) - - (best_config, best_score, best_context) = optimizer.get_best_observations() - assert isinstance(best_config, pd.DataFrame) - assert isinstance(best_score, pd.DataFrame) - assert best_context is None - assert set(best_config.columns) == {"x", "y", "z"} - assert set(best_score.columns) == {"score"} - assert best_config.shape == (1, 3) - assert best_score.shape == (1, 1) - assert best_score.score.iloc[0] < -5 - - (all_configs, all_scores, all_contexts) = optimizer.get_observations() - assert isinstance(all_configs, pd.DataFrame) - assert isinstance(all_scores, pd.DataFrame) - assert all_contexts is None - assert set(all_configs.columns) == {"x", "y", "z"} - assert set(all_scores.columns) == {"score"} - assert all_configs.shape == (20, 3) - assert all_scores.shape == (20, 1) + inp: Any = suggestion.config["x"] + assert isinstance(inp, (int, float)) + observation = objective(inp) + assert isinstance(observation, pd.Series) + optimizer.register(observations=suggestion.complete(observation)) + + best_observation = optimizer.get_best_observations() + assert isinstance(best_observation, Observations) + assert isinstance(best_observation.configs, pd.DataFrame) + assert isinstance(best_observation.scores, pd.DataFrame) + assert best_observation.contexts is None + assert set(best_observation.configs.columns) == {"x", "y", "z"} + assert set(best_observation.scores.columns) == {"score"} + assert best_observation.configs.shape == (1, 3) + assert best_observation.scores.shape == (1, 1) + assert best_observation.scores.score.iloc[0] < -4 + + all_observations = optimizer.get_observations() + assert isinstance(all_observations, Observations) + assert isinstance(all_observations.configs, pd.DataFrame) + assert isinstance(all_observations.scores, pd.DataFrame) + assert all_observations.contexts is None + assert set(all_observations.configs.columns) == {"x", "y", "z"} + assert set(all_observations.scores.columns) == {"score"} + assert all_observations.configs.shape == (20, 3) + assert all_observations.scores.shape == (20, 1) # It would be better to put this into bayesian_optimizer_test but then we'd have # to refit the model if isinstance(optimizer, BaseBayesianOptimizer): - pred_best = optimizer.surrogate_predict(configs=best_config) - assert pred_best.shape == (1,) + pred_best = [ + optimizer.surrogate_predict(suggestion=observation.to_suggestion()) + for observation in best_observation + ] + assert len(pred_best) == 1 - pred_all = optimizer.surrogate_predict(configs=all_configs) - assert pred_all.shape == (20,) + pred_all = [ + optimizer.surrogate_predict(suggestion=observation.to_suggestion()) + for observation in all_observations + ] + assert len(pred_all) == 20 @pytest.mark.parametrize( @@ -226,10 +242,10 @@ def test_optimizer_with_llamatune(optimizer_type: OptimizerType, kwargs: Optiona if kwargs is None: kwargs = {} - def objective(point: pd.DataFrame) -> pd.DataFrame: + def objective(point: pd.Series) -> pd.Series: # Best value can be reached by tuning an 1-dimensional search space - ret = pd.DataFrame({"score": np.sin(point.x * point.y)}) - assert ret.score.hasnans is False + ret: pd.Series = pd.Series({"score": np.sin(point.x * point.y)}) + assert pd.notna(ret.score) return ret input_space = CS.ConfigurationSpace(seed=1234) @@ -291,56 +307,58 @@ def objective(point: pd.DataFrame) -> pd.DataFrame: _LOG.debug("Optimizer is done with random init.") # loop for optimizer - suggestion, metadata = optimizer.suggest() - observation = objective(suggestion) - optimizer.register(configs=suggestion, scores=observation, metadata=metadata) + suggestion = optimizer.suggest() + observation = objective(suggestion.config) + optimizer.register(observations=suggestion.complete(observation)) # loop for llamatune-optimizer - suggestion, metadata = llamatune_optimizer.suggest() - _x, _y = suggestion["x"].iloc[0], suggestion["y"].iloc[0] + suggestion = llamatune_optimizer.suggest() + _x, _y = suggestion.config["x"], suggestion.config["y"] # optimizer explores 1-dimensional space assert _x == pytest.approx(_y, rel=1e-3) or _x + _y == pytest.approx(3.0, rel=1e-3) - observation = objective(suggestion) - llamatune_optimizer.register(configs=suggestion, scores=observation, metadata=metadata) + observation = objective(suggestion.config) + llamatune_optimizer.register(observations=suggestion.complete(observation)) # Retrieve best observations - best_observation = optimizer.get_best_observations() - llamatune_best_observation = llamatune_optimizer.get_best_observations() - - for best_config, best_score, best_context in (best_observation, llamatune_best_observation): - assert isinstance(best_config, pd.DataFrame) - assert isinstance(best_score, pd.DataFrame) - assert best_context is None - assert set(best_config.columns) == {"x", "y"} - assert set(best_score.columns) == {"score"} - - (best_config, best_score, _context) = best_observation - (llamatune_best_config, llamatune_best_score, _context) = llamatune_best_observation + best_observation: Observations = optimizer.get_best_observations() + assert isinstance(best_observation, Observations) + llamatune_best_observations: Observations = llamatune_optimizer.get_best_observations() + assert isinstance(llamatune_best_observations, Observations) + + for observations in (best_observation, llamatune_best_observations): + assert isinstance(observations.configs, pd.DataFrame) + assert isinstance(observations.scores, pd.DataFrame) + assert observations.contexts is None + assert set(observations.configs.columns) == {"x", "y"} + assert set(observations.scores.columns) == {"score"} # LlamaTune's optimizer score should better (i.e., lower) than plain optimizer's # one, or close to that assert ( - best_score.score.iloc[0] > llamatune_best_score.score.iloc[0] - or best_score.score.iloc[0] + 1e-3 > llamatune_best_score.score.iloc[0] + best_observation.scores.score.iloc[0] > llamatune_best_observations.scores.score.iloc[0] + or best_observation.scores.score.iloc[0] + 1e-3 + > llamatune_best_observations.scores.score.iloc[0] ) # Retrieve and check all observations - for all_configs, all_scores, all_contexts in ( + for all_observations in ( optimizer.get_observations(), llamatune_optimizer.get_observations(), ): - assert isinstance(all_configs, pd.DataFrame) - assert isinstance(all_scores, pd.DataFrame) - assert all_contexts is None - assert set(all_configs.columns) == {"x", "y"} - assert set(all_scores.columns) == {"score"} - assert len(all_configs) == num_iters - assert len(all_scores) == num_iters + assert isinstance(all_observations.configs, pd.DataFrame) + assert isinstance(all_observations.scores, pd.DataFrame) + assert all_observations.contexts is None + assert set(all_observations.configs.columns) == {"x", "y"} + assert set(all_observations.scores.columns) == {"score"} + assert len(all_observations.configs) == num_iters + assert len(all_observations.scores) == num_iters + assert len(all_observations) == num_iters # .surrogate_predict method not currently implemented if space adapter is employed if isinstance(llamatune_optimizer, BaseBayesianOptimizer): with pytest.raises(NotImplementedError): - llamatune_optimizer.surrogate_predict(configs=llamatune_best_config) + for obs in llamatune_best_observations: + llamatune_optimizer.surrogate_predict(suggestion=obs.to_suggestion()) # Dynamically determine all of the optimizers we have implemented. @@ -381,9 +399,10 @@ def test_mixed_numerics_type_input_space_types( if kwargs is None: kwargs = {} - def objective(point: pd.DataFrame) -> pd.DataFrame: + def objective(point: pd.Series) -> pd.Series: # mix of hyperparameters, optimal is to select the highest possible - return pd.DataFrame({"score": point["x"] + point["y"]}) + ret: pd.Series = pd.Series({"score": point["x"] + point["y"]}) + return ret input_space = CS.ConfigurationSpace(seed=SEED) # add a mix of numeric datatypes @@ -404,6 +423,8 @@ def objective(point: pd.DataFrame) -> pd.DataFrame: optimizer_kwargs=kwargs, ) + assert isinstance(optimizer, BaseOptimizer) + with pytest.raises(ValueError, match="No observations"): optimizer.get_best_observations() @@ -411,29 +432,30 @@ def objective(point: pd.DataFrame) -> pd.DataFrame: optimizer.get_observations() for _ in range(max_iterations): - suggestion, metadata = optimizer.suggest() - assert isinstance(suggestion, pd.DataFrame) - assert (suggestion.columns == ["x", "y"]).all() + suggestion = optimizer.suggest() + assert isinstance(suggestion, Suggestion) + assert isinstance(suggestion.config, pd.Series) + assert set(suggestion.config.index) == {"x", "y"} # Check suggestion values are the expected dtype - assert isinstance(suggestion["x"].iloc[0], np.integer) - assert isinstance(suggestion["y"].iloc[0], np.floating) + assert isinstance(suggestion.config["x"], int) + assert isinstance(suggestion.config["y"], float) # Check that suggestion is in the space test_configuration = CS.Configuration( - optimizer.parameter_space, suggestion.astype("O").iloc[0].to_dict() + optimizer.parameter_space, suggestion.config.to_dict() ) # Raises an error if outside of configuration space test_configuration.check_valid_configuration() # Test registering the suggested configuration with a score. - observation = objective(suggestion) - assert isinstance(observation, pd.DataFrame) - optimizer.register(configs=suggestion, scores=observation, metadata=metadata) - - (best_config, best_score, best_context) = optimizer.get_best_observations() - assert isinstance(best_config, pd.DataFrame) - assert isinstance(best_score, pd.DataFrame) - assert best_context is None - - (all_configs, all_scores, all_contexts) = optimizer.get_observations() - assert isinstance(all_configs, pd.DataFrame) - assert isinstance(all_scores, pd.DataFrame) - assert all_contexts is None + observation = objective(suggestion.config) + assert isinstance(observation, pd.Series) + optimizer.register(observations=suggestion.complete(observation)) + + best_observations = optimizer.get_best_observations() + assert isinstance(best_observations.configs, pd.DataFrame) + assert isinstance(best_observations.scores, pd.DataFrame) + assert best_observations.contexts is None + + all_observations = optimizer.get_observations() + assert isinstance(all_observations.configs, pd.DataFrame) + assert isinstance(all_observations.scores, pd.DataFrame) + assert all_observations.contexts is None diff --git a/mlos_core/mlos_core/tests/spaces/adapters/identity_adapter_test.py b/mlos_core/mlos_core/tests/spaces/adapters/identity_adapter_test.py index 107ea5263bd..32ac7f078e3 100644 --- a/mlos_core/mlos_core/tests/spaces/adapters/identity_adapter_test.py +++ b/mlos_core/mlos_core/tests/spaces/adapters/identity_adapter_test.py @@ -22,21 +22,21 @@ def test_identity_adapter() -> None: adapter = IdentityAdapter(orig_parameter_space=input_space) num_configs = 10 - for sampled_config in input_space.sample_configuration( + for ( + sampled_config + ) in input_space.sample_configuration( # pylint: disable=not-an-iterable # (false positive) size=num_configs - ): # pylint: disable=not-an-iterable # (false positive) - sampled_config_df = pd.DataFrame( - [sampled_config.values()], columns=list(sampled_config.keys()) - ) - target_config_df = adapter.inverse_transform(sampled_config_df) - assert target_config_df.equals(sampled_config_df) + ): + sampled_config_sr = pd.Series(dict(sampled_config)) + target_config_sr = adapter.inverse_transform(sampled_config_sr) + assert target_config_sr.equals(sampled_config_sr) target_config = CS.Configuration( - adapter.target_parameter_space, values=target_config_df.iloc[0].to_dict() + adapter.target_parameter_space, values=target_config_sr.to_dict() ) assert target_config == sampled_config - orig_config_df = adapter.transform(target_config_df) - assert orig_config_df.equals(sampled_config_df) + orig_config_df = adapter.transform(target_config_sr) + assert orig_config_df.equals(sampled_config_sr) orig_config = CS.Configuration( - adapter.orig_parameter_space, values=orig_config_df.iloc[0].to_dict() + adapter.orig_parameter_space, values=orig_config_df.to_dict() ) assert orig_config == sampled_config diff --git a/mlos_core/mlos_core/tests/spaces/adapters/llamatune_test.py b/mlos_core/mlos_core/tests/spaces/adapters/llamatune_test.py index 7ca3c0d6eca..d1494ad4578 100644 --- a/mlos_core/mlos_core/tests/spaces/adapters/llamatune_test.py +++ b/mlos_core/mlos_core/tests/spaces/adapters/llamatune_test.py @@ -118,22 +118,20 @@ def test_num_low_dims( sampled_configs = adapter.target_parameter_space.sample_configuration(size=100) for sampled_config in sampled_configs: # pylint: disable=not-an-iterable # (false positive) # Transform low-dim config to high-dim point/config - sampled_config_df = pd.DataFrame( - [sampled_config.values()], columns=list(sampled_config.keys()) - ) - orig_config_df = adapter.transform(sampled_config_df) + sampled_config_sr = pd.Series(dict(sampled_config)) + orig_config_sr = adapter.transform(sampled_config_sr) # High-dim (i.e., original) config should be valid - orig_config = CS.Configuration(input_space, values=orig_config_df.iloc[0].to_dict()) + orig_config = CS.Configuration(input_space, values=orig_config_sr.to_dict()) orig_config.check_valid_configuration() # Transform high-dim config back to low-dim - target_config_df = adapter.inverse_transform(orig_config_df) + target_config_sr = adapter.inverse_transform(orig_config_sr) # Sampled config and this should be the same target_config = CS.Configuration( adapter.target_parameter_space, - values=target_config_df.iloc[0].to_dict(), + values=target_config_sr.to_dict(), ) assert target_config == sampled_config @@ -143,16 +141,15 @@ def test_num_low_dims( unseen_sampled_config ) in unseen_sampled_configs: # pylint: disable=not-an-iterable # (false positive) if ( - unseen_sampled_config in sampled_configs - ): # pylint: disable=unsupported-membership-test # (false positive) + unseen_sampled_config + in sampled_configs # pylint: disable=unsupported-membership-test # (false positive) + ): continue - unseen_sampled_config_df = pd.DataFrame( - [unseen_sampled_config.values()], columns=list(unseen_sampled_config.keys()) - ) + unseen_sampled_config_sr = pd.Series(dict(unseen_sampled_config)) with pytest.raises(ValueError): _ = adapter.inverse_transform( - unseen_sampled_config_df + unseen_sampled_config_sr ) # pylint: disable=redefined-variable-type @@ -241,13 +238,11 @@ def test_special_parameter_values_validation() -> None: def gen_random_configs(adapter: LlamaTuneAdapter, num_configs: int) -> Iterator[CS.Configuration]: for sampled_config in adapter.target_parameter_space.sample_configuration(size=num_configs): # Transform low-dim config to high-dim config - sampled_config_df = pd.DataFrame( - [sampled_config.values()], columns=list(sampled_config.keys()) - ) - orig_config_df = adapter.transform(sampled_config_df) + sampled_config_sr = pd.Series(dict(sampled_config)) + orig_config_sr = adapter.transform(sampled_config_sr) orig_config = CS.Configuration( adapter.orig_parameter_space, - values=orig_config_df.iloc[0].to_dict(), + values=orig_config_sr.to_dict(), ) yield orig_config @@ -429,10 +424,8 @@ def test_approx_inverse_mapping( sampled_config = input_space.sample_configuration() # size=1) with pytest.raises(ValueError): - sampled_config_df = pd.DataFrame( - [sampled_config.values()], columns=list(sampled_config.keys()) - ) - _ = adapter.inverse_transform(sampled_config_df) + sampled_config_sr = pd.Series(dict(sampled_config)) + _ = adapter.inverse_transform(sampled_config_sr) # Enable low-dimensional space projection *and* reverse mapping adapter = LlamaTuneAdapter( @@ -446,28 +439,24 @@ def test_approx_inverse_mapping( # Warning should be printed the first time sampled_config = input_space.sample_configuration() # size=1) with pytest.warns(UserWarning): - sampled_config_df = pd.DataFrame( - [sampled_config.values()], columns=list(sampled_config.keys()) - ) - target_config_df = adapter.inverse_transform(sampled_config_df) + sampled_config_sr = pd.Series(dict(sampled_config)) + target_config_sr = adapter.inverse_transform(sampled_config_sr) # Low-dim (i.e., target) config should be valid target_config = CS.Configuration( adapter.target_parameter_space, - values=target_config_df.iloc[0].to_dict(), + values=target_config_sr.to_dict(), ) target_config.check_valid_configuration() # Test inverse transform with 100 random configs for _ in range(100): sampled_config = input_space.sample_configuration() # size=1) - sampled_config_df = pd.DataFrame( - [sampled_config.values()], columns=list(sampled_config.keys()) - ) - target_config_df = adapter.inverse_transform(sampled_config_df) + sampled_config_sr = pd.Series(dict(sampled_config)) + target_config_sr = adapter.inverse_transform(sampled_config_sr) # Low-dim (i.e., target) config should be valid target_config = CS.Configuration( adapter.target_parameter_space, - values=target_config_df.iloc[0].to_dict(), + values=target_config_sr.to_dict(), ) target_config.check_valid_configuration() @@ -520,22 +509,24 @@ def test_llamatune_pipeline( unique_values_dict: Dict[str, Set] = {param: set() for param in input_space.keys()} num_configs = 1000 - for config in adapter.target_parameter_space.sample_configuration( + for ( + config + ) in adapter.target_parameter_space.sample_configuration( # pylint: disable=not-an-iterable size=num_configs - ): # pylint: disable=not-an-iterable + ): # Transform low-dim config to high-dim point/config - sampled_config_df = pd.DataFrame([config.values()], columns=list(config.keys())) - orig_config_df = adapter.transform(sampled_config_df) + sampled_config_sr = pd.Series(dict(config)) + orig_config_sr = adapter.transform(sampled_config_sr) # High-dim (i.e., original) config should be valid - orig_config = CS.Configuration(input_space, values=orig_config_df.iloc[0].to_dict()) + orig_config = CS.Configuration(input_space, values=orig_config_sr.to_dict()) orig_config.check_valid_configuration() # Transform high-dim config back to low-dim - target_config_df = adapter.inverse_transform(orig_config_df) + target_config_sr = adapter.inverse_transform(orig_config_sr) # Sampled config and this should be the same target_config = CS.Configuration( adapter.target_parameter_space, - values=target_config_df.iloc[0].to_dict(), + values=target_config_sr.to_dict(), ) assert target_config == config diff --git a/mlos_core/mlos_core/util.py b/mlos_core/mlos_core/util.py index 2e1c382a31a..54d2092f8f9 100644 --- a/mlos_core/mlos_core/util.py +++ b/mlos_core/mlos_core/util.py @@ -4,15 +4,60 @@ # """Internal helper functions for mlos_core package.""" -from typing import Union +from typing import Optional, Union import pandas as pd from ConfigSpace import Configuration, ConfigurationSpace -def config_to_dataframe(config: Configuration) -> pd.DataFrame: +def compare_optional_series(left: Optional[pd.Series], right: Optional[pd.Series]) -> bool: """ - Converts a ConfigSpace config to a DataFrame. + Compare Series that may also be None. + + Parameters + ---------- + left : Optional[pandas.Series] + The left Series to compare + right : Optional[pandas.Series] + The right Series to compare + + Returns + ------- + bool + Compare the equality of two Optional[pd.Series] objects + """ + if isinstance(left, pd.Series) and isinstance(right, pd.Series): + return left.equals(right) + return left is None and right is None + + +def compare_optional_dataframe( + left: Optional[pd.DataFrame], + right: Optional[pd.DataFrame], +) -> bool: + """ + Compare DataFrames that may also be None. + + Parameters + ---------- + left : Optional[pandas.DataFrame] + The left DataFrame to compare + right : Optional[pandas.DataFrame] + The right DataFrame to compare + + Returns + ------- + bool + Compare the equality of two Optional[pd.DataFrame] objects + """ + if isinstance(left, pd.DataFrame) and isinstance(right, pd.DataFrame): + return left.equals(right) + return left is None and right is None + + +def config_to_series(config: Configuration) -> pd.Series: + """ + Converts a ConfigSpace config to a Series. Parameters ---------- @@ -21,10 +66,11 @@ def config_to_dataframe(config: Configuration) -> pd.DataFrame: Returns ------- - pandas.DataFrame - A DataFrame with a single row, containing the config's parameters. + pandas.Series + A Series, containing the config's parameters. """ - return pd.DataFrame([dict(config)]) + series: pd.Series = pd.Series(dict(config)) # needed for type hinting + return series def drop_nulls(d: dict) -> dict: diff --git a/mlos_core/notebooks/BayesianOptimization.ipynb b/mlos_core/notebooks/BayesianOptimization.ipynb index c0d214076c4..e57aebfadb9 100644 --- a/mlos_core/notebooks/BayesianOptimization.ipynb +++ b/mlos_core/notebooks/BayesianOptimization.ipynb @@ -16,7 +16,8 @@ "source": [ "import matplotlib.pyplot as plt\n", "import numpy as np\n", - "import pandas as pd" + "import pandas as pd\n", + "from mlos_core.data_classes import Suggestion" ] }, { @@ -32,8 +33,8 @@ "def f(x):\n", " return (6*x-2)**2*np.sin(12*x-4)\n", "\n", - "def score_config(config: pd.DataFrame) -> pd.DataFrame:\n", - " return pd.DataFrame(data=[f(config['x'].loc[0])], columns=['score'])" + "def score_config(config: pd.Series) -> pd.Series:\n", + " return pd.Series({\"score\": f(config['x'])})" ] }, { @@ -53,7 +54,7 @@ }, { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -149,12 +150,12 @@ "source": [ "def run_optimization(optimizer: mlos_core.optimizers.BaseOptimizer):\n", " # get a new config value suggestion to try from the optimizer.\n", - " (suggested_value, _metadata) = optimizer.suggest()\n", + " suggestion = optimizer.suggest()\n", " # suggested value are dictionary-like, keys are input space parameter names\n", " # evaluate target function\n", - " scores = score_config(suggested_value)\n", - " #print(suggested_value, \"\\n\", scores)\n", - " optimizer.register(configs=suggested_value, scores=scores)\n", + " scores = score_config(suggestion.config)\n", + " # print(suggested_value, \"\\n\", scores)\n", + " optimizer.register(suggestion.complete(scores))\n", "\n", "# run for some iterations\n", "n_iterations = 15\n", @@ -178,39 +179,37 @@ { "data": { "text/plain": [ - "( x\n", - " 0 0.985143\n", - " 1 0.250745\n", - " 2 0.039215\n", - " 3 0.711113\n", - " 4 0.525156\n", - " 5 0.228304\n", - " 6 0.438647\n", - " 7 0.797999\n", - " 8 0.831501\n", - " 9 0.409664\n", - " 10 0.002271\n", - " 11 0.703873\n", - " 12 0.710294\n", - " 13 0.716369\n", - " 14 0.723490,\n", - " score\n", - " 0 15.286830\n", - " 1 -0.205432\n", - " 2 1.177722\n", - " 3 -5.055697\n", - " 4 0.986147\n", - " 5 -0.378142\n", - " 6 0.380605\n", - " 7 -5.050101\n", - " 8 -2.684311\n", - " 9 0.166364\n", - " 10 2.914733\n", - " 11 -4.769083\n", - " 12 -5.024602\n", - " 13 -5.246319\n", - " 14 -5.477457,\n", - " None)" + "Observation(configs= x\n", + "0 0.748272\n", + "1 0.368592\n", + "2 0.112681\n", + "3 0.98844\n", + "4 0.757126\n", + "5 0.125027\n", + "6 0.384848\n", + "7 0.513056\n", + "8 0.623975\n", + "9 0.493867\n", + "10 0.755699\n", + "11 0.802577\n", + "12 0.762684\n", + "13 0.757328\n", + "14 0.75858, score= score\n", + "0 -5.978851\n", + "1 0.018376\n", + "2 -0.830708\n", + "3 15.449535\n", + "4 -6.020732\n", + "5 -0.935277\n", + "6 0.055367\n", + "7 0.968884\n", + "8 -1.031611\n", + "9 0.869712\n", + "10 -6.019463\n", + "11 -4.811461\n", + "12 -6.004725\n", + "13 -6.020737\n", + "14 -6.019791, contexts={self._contexts}, metadata={self._metadata})" ] }, "execution_count": 9, @@ -229,7 +228,7 @@ "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjUAAAGwCAYAAABRgJRuAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguNCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8fJSN1AAAACXBIWXMAAA9hAAAPYQGoP6dpAAB9uklEQVR4nO3dd3hT5dvA8W+6d0tbOoBCC6VAKRvBgsqQrYiCCggC4gJElojyKjJUUPyBigo4GQoCAqIIMmRvKFBWyy67pUChLd1NzvtHaCB0kJSkadP7c125mpxzcnLndOTuM+5HpSiKghBCCCFEGWdj6QCEEEIIIUxBkhohhBBCWAVJaoQQQghhFSSpEUIIIYRVkKRGCCGEEFZBkhohhBBCWAVJaoQQQghhFewsHUBJ0mg0XLlyBXd3d1QqlaXDEUIIIYQBFEUhNTWVSpUqYWNTeHtMuUpqrly5QlBQkKXDEEIIIUQxXLx4kSpVqhS6v1wlNe7u7oD2onh4eFg4GiGEEEIYIiUlhaCgIN3neGHKVVKT1+Xk4eEhSY0QQghRxjxo6IgMFBZCCCGEVZCkRgghhBBWQZIaIYQQQliFcjWmxlBqtZqcnBxLhyGERdnb22Nra2vpMIQQwmCS1NxDURQSEhK4deuWpUMRolTw8vIiICBA6joJIcoESWrukZfQ+Pn54eLiIn/IRbmlKArp6ekkJiYCEBgYaOGIhBDiwSSpuUOtVusSGh8fH0uHI4TFOTs7A5CYmIifn590RQkhSj0ZKHxH3hgaFxcXC0ciROmR9/sgY8yEEGWBJDX3kS4nIe6S3wchRFkiSY0QQgghrIIkNUIIIYSwCpLUWLnNmzejUqmsZpp6Sb2fAQMG8Oyzz5r1NYQQQpiWzH4yMbVGYW9cEompmfi5O9EsxBtbGxmXUNZ8/fXXKIpi1HNUKhV//vmnJENCiHLp8q0MktNzCK9kuQWjJakxoTVH45m4Mob45EzdtkBPJ8Z3DadTRPmp85GdnY2Dg4Olw3gonp6elg5BCCHKDI1GYfSSQ0SdT+KL5xvwbKPKFolDup9MZM3ReAb/dkAvoQFISM5k8G8HWHM03iyvm5WVxbBhw/Dz88PJyYnHHnuMffv25Ttux44dNGjQACcnJ5o3b86RI0d0+86fP0/Xrl2pUKECrq6u1K1bl9WrV+v2x8TE0KVLF9zc3PD39+fll1/m+vXruv2tW7dm6NChjBo1Cl9fX9q3b0/v3r3p1auXXgw5OTn4+voyZ84cQFvgberUqVSvXh1nZ2caNGjA0qVL9Z6zevVqwsLCcHZ2pk2bNpw7d+6B10SlUjFr1iw6d+6Ms7MzISEh/PHHH3rHHDlyhLZt2+Ls7IyPjw9vvPEGt2/f1u2/v/updevWDBs2jDFjxuDt7U1AQAATJkzQ7Q8ODgbgueeeQ6VS6R4fOnSINm3a4O7ujoeHB02aNCEqKuqB70EIIcqSOTvPsevsDexsbGgY5GWxOCSpMQG1RmHiyhgK6qzI2zZxZQxqjXHdGYYYM2YMy5YtY968eRw4cIDQ0FA6duxIUlKS3nHvvvsu//vf/9i3bx9+fn4888wzutojb731FllZWWzdupUjR47w+eef4+bmBkB8fDytWrWiYcOGREVFsWbNGq5evcqLL76od/558+ZhZ2fHjh07+P777+nTpw9///23XqKwdu1a0tLS6NGjBwAffvghc+bMYdasWRw7doyRI0fSt29ftmzZAsDFixfp3r07Xbp0ITo6mtdee43333/foOsybtw4evTowaFDh+jbty+9e/cmNjYWgPT0dDp16kSFChXYt28ff/zxB//99x9Dhw4t8pzz5s3D1dWVPXv2MHXqVCZNmsT69esBdInknDlziI+P1z3u06cPVapUYd++fezfv5/3338fe3t7g96DEEKUBaeupvL5muMAfPBUHYJ9XS0XjFKOJCcnK4CSnJycb19GRoYSExOjZGRkGH3enaevK9Xe++eBt52nr5vibejcvn1bsbe3VxYsWKDblp2drVSqVEmZOnWqoiiKsmnTJgVQFi1apDvmxo0birOzs7J48WJFURSlXr16yoQJEwp8jXHjxikdOnTQ23bx4kUFUE6cOKEoiqK0atVKadiwod4x2dnZiq+vrzJ//nzdtt69eysvvPCCLnYnJydl586des979dVXld69eyuKoihjx45V6tSpo2g0Gt3+9957TwGUmzdvFnpdAGXQoEF625o3b64MHjxYURRF+eGHH5QKFSoot2/f1u1ftWqVYmNjoyQkJCiKoij9+/dXunXrptvfqlUr5bHHHtM75yOPPKK89957eq/7559/6h3j7u6uzJ07t9BYS7uH+b0QQli/7Fy18vSMbUq19/5R+v28R+/vtSkV9fl9LxlTYwKJ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m5vLnj17dNtu3LjByZMndd0XtWvXZu/evXrPi4qK0nu8bds2BgwYwHPPPUe9evUICAjQG4AL2lYitVqtt61x48YcO3aM0NDQfLeiZitduHCBK1fuJqe7du3CxsZGNyC4II0bN+bUqVP4+fnley1PT088PT0JDAxk9+7duufk5uayf//+Qs9Zr149NBoNW7ZsKXB/3nu4/33fy5DvgaEaNWrE2LFj2blzJxERESxcuNCo5wshyra462kkpGTiYGtD0+AKlg7HZCSpKcP27NnD5MmTiYqK4sKFCyxfvpxr167pPuDatm3LqlWrWLVqFcePH2fIkCF6M3+MUbNmTbp168brr7/O9u3bOXToEH379qVy5cp069YNgLfffpvVq1czffp0Tp06xffff8+///6r14oQGhrK8uXLiY6O5tChQ7z00ktoNBq91woODmbr1q1cvnyZ69e1zaPvvfceu3bt4q233iI6Olo3vuTtt98uMm4nJyf69+/PoUOH2LZtG8OGDePFF1/UG69zvz59+uDr60u3bt3Ytm0bcXFxbNmyheHDh3Pp0iUAhg8fzmeffcaff/5p0LUNDg6mf//+DBw4kBUrVhAXF8fmzZtZsmQJANWqVUOlUvHPP/9w7do1bt++XazvwYPExcUxduxYdu3axfnz51m3bl2xkiIhRNm2486sp8bVvHCyL7sLWN5PkpoyzMPDg61bt9KlSxfCwsL48MMPmTZtGp07dwZg4MCB9O/fn379+tGqVStCQkJ0U4eLY86cOTRp0oSnn36ayMhIFEVh9erVuu6Oli1bMnv2bKZPn06DBg1Ys2YNI0eO1BvE++WXX1KhQgVatGhB165d6dixI40bN9Z7nUmTJnHu3Dlq1Kih6+6pX78+W7Zs4dSpUzz++OM0atSIcePGERgYWGTMoaGhdO/enS5dutChQwciIiKYOXNmkc9xcXFh69atVK1ale7du1OnTh0GDhxIRkYGHh4eALzzzjv069ePAQMGEBkZibu7O88991yR5501axbPP/88Q4YMoXbt2rz++uu6KemVK1dm4sSJvP/++/j7+xc6q+tB34MHcXFx4fjx4/To0YOwsDDeeOMNhg4dyptvvmnQ84UQ1mGXbjyN9XQ9AagUY+f/lmEpKSl4enqSnJys+3DKk5mZSVxcHCEhIYXOpBHGe/311zl+/Djbtm0r8dee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", + "image/png": "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d3dl/vz5JcZa0ZXl90IIUbn8ffiyEnqXZ9yMtcfMdu27Pb9vJyU+5SQpI+feOxmxn6FOnz5Nfn4+7dq1062zt7enVatWutKNQjExMbplHx8f6tWrp9tn+PDhTJo0iXbt2jF+/HgOHTqk23ffvn1s2LABNzc33at+/fq66xeKjo7Wu569vT19+vRh0aJFAGRlZfH777/Tv39/QFt9lpOTQ5cuXfTOvXDhQt154+PjadOmjd4gerd/jru5c7+YmBjd542PjycqKgpXV1fd9nbt2qHRaDh+/HiJ52zSpIne+6CgIN1cViUZNWoUL774Ig8++CAffvih3j0TQghrcbeajUJL9140S82GMSTxKSf+7oYV6xm6n6GUW72O7hxdV1EUg0bcLdznxRdf5MyZMwwYMIDDhw8THR3Nl19+CYBGo6Fnz57ExsbqvU6ePMn999+vO9ftSUSh/v37888//5CUlMRvv/2Gk5MT3bt3150XYNWqVXrnjYuL07XzUYzsVWXo573b/bnbfbuzikqlUuk+R0kmTJjA0aNHefjhh/n333+JjIxkxYoVRkYuhBCWtTshpUgb1jslpuWwOyHlrvuYmyQ+5aRVmA9Bnk6U9MhUoe3dZepW7uHh4Tg4OLB161bduvz8fPbu3UuDBg309t25c6du+caNG5w4cUJXcgMQEhLCkCFDWL58OW+++SZz5swBoHnz5hw9epTQ0FDCw8P1XsUlO7dr27YtISEhLFmyhEWLFtGnTx9db6/IyEgcHR05f/58kfOGhITo9rk97js/x90Ud1zh542MjCQ2NpasrCzd9m3btmFjY0NERIRB5y+Ovb29bs6320VERDBy5EjWrl1Lr169dI27hRDCWliqZsNYVpX4bN68mZ49exIcHIxKpeK3337T2z548GBUKpXeq02bNpYJ9g62NirG94wEKJL8FL4f3zPS5OP5uLq68uqrr/K///2P1atXExcXx0svvUR2djYvvPCC3r7vv/8+69ev58iRIwwePBg/Pz9dr6U33niDNWvWkJCQwP79+/n33391idNrr71GSkoKzzzzDLt37+bMmTOsXbuW559/vtiHvN5nV6no168fs2fPZt26dTz77LO6be7u7owePZqRI0eyYMECTp8+zYEDB/j6669ZsGABAEOGDOH06dOMGjWK48eP89NPPzF//nyD7s2yZcv4/vvv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IUP09mndurXy6quvKoqiKBs2bFAA5caNG7rtBw4cUAAlISFBURRFmTdvnuLp6XnP+GbMmKHUrl1b9/748eMKoBw9erTEY4YOHar07t1b7zMGBAQoubm5evvVqlVL+fTTT0s8z9KlSxVfX1/d+3nz5imAsnPnTt26+Ph4BVB27dqlKIqijB8/XomKitJtDwkJUX766Se9837wwQdKTExMidcVQhhOSnyEEGXWpEkTvfdBQUEkJSXprYuJiSny3tASH2M8/fTTnDt3jp07dwKwaNEimjZtSmRkpG6f2bNnEx0dTbVq1XBzc2POnDmcP39e7zyNGzfGwcHhrtfasGEDXbp0oXr16ri7uzNw4ECSk5PJysrS7WNnZ0d0dLTuff369fHy8ir2s1+7do0LFy7wwgsv4ObmpntNmjSJ06dPl+p+CCH0SeIjhCgze3t7vfeFVUr3olKpAG2VEYCiKLpt+fn5pYolKCiITp068dNPPwHw888/8+yzz+q2L126lJEjR/L888+zdu1aYmNjee6558jLy9M7j6ur612vc+7cOXr06EGjRo349ddf2bdvH19//XWxsRd+znutK7xnc+bMITY2Vvc6cuSILpETQpSNJD5CiHJx54N7586d1K9fH0DXWyoxMVG3PTY2Vm9/BwcH1Gq1Qdfq378/S5YsYceOHZw+fVqvTc2WLVto27YtQ4cOpVmzZoSHh5eqNGXv3r0UFBQwffp02rRpQ0REBJcvXy6yX0FBAXv37tW9P378OKmpqbrPfruAgACqV6/OmTNnCA8P13uFhYUZHaMQoihJfIQQ5WLZsmV8//33nDhxgvHjx7N7926GDRsGQHh4OCEhIUyYMIETJ06watUqpk+frnd8aGgomZmZrF+/nuvXr5OdnV3itXr16kV6ejqvvvoqnTp1onr16rpt4eHh7N27lzVr1nDixAnGjRvHnj17jP48derUoaCggC+//JIzZ87www8/MHv27CL72dvb8/rrr7Nr1y7279/Pc889R5s2bWjVqlWx550wYQJTp07l888/58SJExw+fJh58+YxY8YMo2MUQhQliY8QolxMnDiRxYsX06RJExYsWMCiRYt07W7s7e35+eefOXbsGFFRUXz00UdMmjRJ7/i2bdsyZMgQ+vbtS7Vq1Zg2bVqJ1/Lw8KBnz54cPHiQ/v37620bMmQIvXr1om/fvrRu3Zrk5GSGDh1q9Odp2rQpM2bM4KOPPqJRo0YsWrSIqVOnFtnPxcWFt956i379+hETE4OzszOLFy8u8bwvvvgic+fOZf78+TRu3JgOHTowf/58KfERwkRUyu2V6kIIYQYqlYoVK1ZYZMRnIYS4nZT4CCGEEKLKkMRHCCGEEFWGzNUlhDA7qVEXQlQUUuIjhBBCiCpDEh8hhBBCVBmS+AghhBCiypDERwghhBBVhiQ+QgghhKgyJPERQgghRJUhiY8QQgghqgxJfIQQQghRZfw/RLPK66OPa7UAAAAASUVORK5CYII=", "text/plain": [ "
" ] @@ -241,13 +240,13 @@ "source": [ "# evaluate the surrogate function\n", "#\n", - "configs = pd.DataFrame(line.reshape(-1, 1), columns=['x'])\n", - "surrogate_predictions = optimizer.surrogate_predict(configs=configs)\n", + "\n", + "observations = optimizer.get_observations()\n", + "surrogate_predictions = [optimizer.surrogate_predict(Suggestion(config=pd.Series({\"x\": l}))) for l in line]\n", "\n", "# plot the observations\n", "#\n", - "(observations, scores, _contexts) = optimizer.get_observations()\n", - "plt.scatter(observations.x, scores, label='observed points')\n", + "plt.scatter(x=observations.configs[\"x\"], y=observations.scores[\"score\"], label=\"observed points\")\n", "\n", "# plot the true function (usually unknown)\n", "#\n", @@ -260,8 +259,8 @@ "# ci_radii = t_values * np.sqrt(surrogate_predictions['predicted_value_variance'])\n", "# value = surrogate_predictions['predicted_value']\n", "plt.plot(line, surrogate_predictions, label='surrogate predictions')\n", - "#plt.fill_between(line, value - ci_radii, value + ci_radii, alpha=.1)\n", - "#plt.plot(line, -optimizer.experiment_designer.utility_function(optimization_problem.construct_feature_dataframe(pd.DataFrame({'x': line}))), ':', label='utility_function')\n", + "# plt.fill_between(line, value - ci_radii, value + ci_radii, alpha=.1)\n", + "# plt.plot(line, -optimizer.experiment_designer.utility_function(optimization_problem.construct_feature_dataframe(pd.DataFrame({'x': line}))), ':', label='utility_function')\n", "plt.ylabel(\"Objective function f (performance)\")\n", "plt.xlabel(\"Input variable\")\n", "plt.legend()\n", @@ -284,11 +283,9 @@ { "data": { "text/plain": [ - "( x\n", - " 14 0.72349,\n", - " score\n", - " 14 -5.477457,\n", - " None)" + "Observation(configs= x\n", + "0 0.757328, score= score\n", + "0 -6.020737, contexts={self._contexts}, metadata={self._metadata})" ] }, "execution_count": 11, @@ -336,11 +333,9 @@ { "data": { "text/plain": [ - "( x\n", - " 42 0.757248,\n", - " score\n", - " 42 -6.02074,\n", - " None)" + "Observation(configs= x\n", + "0 0.757249, score= score\n", + "0 -6.02074, contexts={self._contexts}, metadata={self._metadata})" ] }, "execution_count": 13, @@ -363,22 +358,22 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 14, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 15, + "execution_count": 14, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -390,13 +385,15 @@ "source": [ "# evaluate the surrogate function\n", "#\n", - "configs = pd.DataFrame(line.reshape(-1, 1), columns=['x'])\n", - "surrogate_predictions = optimizer.surrogate_predict(configs=configs)\n", + "observations = optimizer.get_observations()\n", + "surrogate_predictions = [\n", + " optimizer.surrogate_predict(Suggestion(config=pd.Series({\"x\": l}))) for l in line\n", + "]\n", "\n", "# plot the observations\n", "#\n", - "(observations, scores, _contexts) = optimizer.get_observations()\n", - "plt.scatter(observations.x, scores, label='observed points')\n", + "observations = optimizer.get_observations()\n", + "plt.scatter(observations.configs.x, observations.scores.score, label='observed points')\n", "\n", "# plot true function (usually unknown)\n", "#\n", @@ -404,27 +401,24 @@ "\n", "# plot the surrogate\n", "#\n", - "#ci_raduii = surrogate_predictions['prediction_ci']\n", + "# ci_raduii = surrogate_predictions['prediction_ci']\n", "plt.plot(line, surrogate_predictions, label='surrogate predictions')\n", - "#plt.fill_between(line, value - ci_radii, value + ci_radii, alpha=.1)\n", - "#plt.plot(line, -optimizer.utility_function(pd.DataFrame({'x': line})), ':', label='utility_function')\n", + "# plt.fill_between(line, value - ci_radii, value + ci_radii, alpha=.1)\n", + "# plt.plot(line, -optimizer.utility_function(pd.DataFrame({'x': line})), ':', label='utility_function')\n", "\n", "ax = plt.gca()\n", "ax.set_ylabel(\"Objective function f\")\n", "ax.set_xlabel(\"Input variable\")\n", "bins_axes = ax.twinx()\n", "bins_axes.set_ylabel(\"Points sampled\")\n", - "pd.DataFrame(observations.x).hist(bins=20, ax=bins_axes, alpha=.3, color='k', label=\"count of sample points\")\n", + "observations.configs.x.hist(bins=20, ax=bins_axes, alpha=.3, color='k', label=\"count of sample points\")\n", "plt.legend()" ] } ], "metadata": { - "interpreter": { - "hash": "dcb43bafd5ce954ead015d0d89e27da6852c23ac7a7b5a1213fc2fecbe919e66" - }, "kernelspec": { - "display_name": "Python [conda env:mlos_core] *", + "display_name": "mlos", "language": "python", "name": "python3" }, @@ -438,7 +432,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.4" + "version": "3.13.0" } }, "nbformat": 4,