From 7bfe02c87d23cf56ff0a05619652c4ffee9ec805 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 21 Mar 2024 13:55:14 +0100 Subject: [PATCH 01/11] Consistenly use plural for hypothesis strategy names --- .../alternative_creation/test_searchspace.py | 4 +- tests/hypothesis_strategies/parameters.py | 50 +++++++++---------- tests/hypothesis_strategies/targets.py | 8 +-- tests/hypothesis_strategies/utils.py | 2 +- .../test_parameter_serialization.py | 24 ++++----- .../test_target_serialization.py | 4 +- 6 files changed, 46 insertions(+), 46 deletions(-) diff --git a/tests/hypothesis_strategies/alternative_creation/test_searchspace.py b/tests/hypothesis_strategies/alternative_creation/test_searchspace.py index ec40e1236..a0f6ab606 100644 --- a/tests/hypothesis_strategies/alternative_creation/test_searchspace.py +++ b/tests/hypothesis_strategies/alternative_creation/test_searchspace.py @@ -15,7 +15,7 @@ from baybe.parameters.categorical import TaskParameter from baybe.searchspace import SearchSpace, SubspaceContinuous from baybe.searchspace.discrete import SubspaceDiscrete -from tests.hypothesis_strategies.parameters import numerical_discrete_parameter +from tests.hypothesis_strategies.parameters import numerical_discrete_parameters # Discrete inputs for testing s_x = pd.Series([1, 2, 3], name="x") @@ -107,7 +107,7 @@ def test_searchspace_creation_from_dataframe(df, parameters, expected): @pytest.mark.parametrize("boundary_only", (False, True)) @given( parameters=st.lists( - numerical_discrete_parameter(min_value=0.0, max_value=1.0), + numerical_discrete_parameters(min_value=0.0, max_value=1.0), min_size=1, max_size=5, unique_by=lambda x: x.name, diff --git a/tests/hypothesis_strategies/parameters.py b/tests/hypothesis_strategies/parameters.py index a718e37e1..94a5de5bf 100644 --- a/tests/hypothesis_strategies/parameters.py +++ b/tests/hypothesis_strategies/parameters.py @@ -19,15 +19,15 @@ from baybe.parameters.substance import SubstanceEncoding, SubstanceParameter from baybe.utils.numerical import DTypeFloatNumpy -from .utils import interval +from .utils import intervals -decorrelation = st.one_of( +decorrelations = st.one_of( st.booleans(), st.floats(min_value=0.0, max_value=1.0, exclude_min=True, exclude_max=True), ) """A strategy that generates decorrelation settings.""" -parameter_name = st.text(min_size=1) +parameter_names = st.text(min_size=1) """A strategy that generates parameter names.""" categories = st.lists(st.text(min_size=1), min_size=2, unique=True) @@ -76,13 +76,13 @@ def custom_descriptors(draw: st.DrawFn): @st.composite -def numerical_discrete_parameter( +def numerical_discrete_parameters( draw: st.DrawFn, min_value: Optional[float] = None, max_value: Optional[float] = None, ): """Generate :class:`baybe.parameters.numerical.NumericalDiscreteParameter`.""" - name = draw(parameter_name) + name = draw(parameter_names) values = draw( st.lists( st.floats( @@ -111,26 +111,26 @@ def numerical_discrete_parameter( @st.composite -def numerical_continuous_parameter(draw: st.DrawFn): +def numerical_continuous_parameters(draw: st.DrawFn): """Generate :class:`baybe.parameters.numerical.NumericalContinuousParameter`.""" - name = draw(parameter_name) - bounds = draw(interval(exclude_half_bounded=True, exclude_fully_unbounded=True)) + name = draw(parameter_names) + bounds = draw(intervals(exclude_half_bounded=True, exclude_fully_unbounded=True)) return NumericalContinuousParameter(name=name, bounds=bounds) @st.composite -def categorical_parameter(draw: st.DrawFn): +def categorical_parameters(draw: st.DrawFn): """Generate :class:`baybe.parameters.categorical.CategoricalParameter`.""" - name = draw(parameter_name) + name = draw(parameter_names) values = draw(categories) encoding = draw(st.sampled_from(CategoricalEncoding)) return CategoricalParameter(name=name, values=values, encoding=encoding) @st.composite -def task_parameter(draw: st.DrawFn): +def task_parameters(draw: st.DrawFn): """Generate :class:`baybe.parameters.categorical.TaskParameter`.""" - name = draw(parameter_name) + name = draw(parameter_names) values = draw(categories) active_values = draw( st.lists(st.sampled_from(values), min_size=1, max_size=len(values), unique=True) @@ -139,11 +139,11 @@ def task_parameter(draw: st.DrawFn): @st.composite -def substance_parameter(draw: st.DrawFn): +def substance_parameters(draw: st.DrawFn): """Generate :class:`baybe.parameters.substance.SubstanceParameter`.""" - name = draw(parameter_name) + name = draw(parameter_names) data = draw(substance_data()) - decorrelate = draw(decorrelation) + decorrelate = draw(decorrelations) encoding = draw(st.sampled_from(SubstanceEncoding)) return SubstanceParameter( name=name, data=data, decorrelate=decorrelate, encoding=encoding @@ -151,22 +151,22 @@ def substance_parameter(draw: st.DrawFn): @st.composite -def custom_parameter(draw: st.DrawFn): +def custom_parameters(draw: st.DrawFn): """Generate :class:`baybe.parameters.custom.CustomDiscreteParameter`.""" - name = draw(parameter_name) + name = draw(parameter_names) data = draw(custom_descriptors()) - decorrelate = draw(decorrelation) + decorrelate = draw(decorrelations) return CustomDiscreteParameter(name=name, data=data, decorrelate=decorrelate) -parameter = st.one_of( +parameters = st.one_of( [ - numerical_discrete_parameter(), - numerical_continuous_parameter(), - categorical_parameter(), - task_parameter(), - substance_parameter(), - custom_parameter(), + numerical_discrete_parameters(), + numerical_continuous_parameters(), + categorical_parameters(), + task_parameters(), + substance_parameters(), + custom_parameters(), ] ) """A strategy that generates parameters.""" diff --git a/tests/hypothesis_strategies/targets.py b/tests/hypothesis_strategies/targets.py index bf153fb04..242f1fffe 100644 --- a/tests/hypothesis_strategies/targets.py +++ b/tests/hypothesis_strategies/targets.py @@ -5,19 +5,19 @@ from baybe.targets.enum import TargetMode from baybe.targets.numerical import _VALID_TRANSFORMATIONS, NumericalTarget -from .utils import interval +from .utils import intervals target_name = st.text(min_size=1) """A strategy that generates target names.""" @st.composite -def numerical_target(draw: st.DrawFn): +def numerical_targets(draw: st.DrawFn): """Generate :class:`baybe.targets.numerical.NumericalTarget`.""" name = draw(target_name) mode = draw(st.sampled_from(TargetMode)) bounds = draw( - interval( + intervals( exclude_half_bounded=True, exclude_fully_unbounded=mode is TargetMode.MATCH ) ) @@ -28,5 +28,5 @@ def numerical_target(draw: st.DrawFn): ) -target = numerical_target() +targets = numerical_targets() """A strategy that generates targets.""" diff --git a/tests/hypothesis_strategies/utils.py b/tests/hypothesis_strategies/utils.py index 54089a1ba..17ed660bd 100644 --- a/tests/hypothesis_strategies/utils.py +++ b/tests/hypothesis_strategies/utils.py @@ -7,7 +7,7 @@ @st.composite -def interval( +def intervals( draw: st.DrawFn, *, exclude_bounded: bool = False, diff --git a/tests/serialization/test_parameter_serialization.py b/tests/serialization/test_parameter_serialization.py index 1053d2d76..cede00672 100644 --- a/tests/serialization/test_parameter_serialization.py +++ b/tests/serialization/test_parameter_serialization.py @@ -9,25 +9,25 @@ from ..conftest import _CHEM_INSTALLED from ..hypothesis_strategies.parameters import ( - categorical_parameter, - custom_parameter, - numerical_continuous_parameter, - numerical_discrete_parameter, - substance_parameter, - task_parameter, + categorical_parameters, + custom_parameters, + numerical_continuous_parameters, + numerical_discrete_parameters, + substance_parameters, + task_parameters, ) @pytest.mark.parametrize( "parameter_strategy", [ - param(numerical_discrete_parameter(), id="NumericalDiscreteParameter"), - param(numerical_continuous_parameter(), id="NumericalContinuousParameter"), - param(categorical_parameter(), id="CategoricalParameter"), - param(task_parameter(), id="TaskParameter"), - param(custom_parameter(), id="CustomParameter"), + param(numerical_discrete_parameters(), id="NumericalDiscreteParameter"), + param(numerical_continuous_parameters(), id="NumericalContinuousParameter"), + param(categorical_parameters(), id="CategoricalParameter"), + param(task_parameters(), id="TaskParameter"), + param(custom_parameters(), id="CustomParameter"), param( - substance_parameter(), + substance_parameters(), id="SubstanceParameter", marks=pytest.mark.skipif( not _CHEM_INSTALLED, reason="Optional chem dependency not installed." diff --git a/tests/serialization/test_target_serialization.py b/tests/serialization/test_target_serialization.py index e30789375..68803ceee 100644 --- a/tests/serialization/test_target_serialization.py +++ b/tests/serialization/test_target_serialization.py @@ -4,10 +4,10 @@ from baybe.targets.base import Target -from ..hypothesis_strategies.targets import target +from ..hypothesis_strategies.targets import targets -@given(target) +@given(targets) def test_parameter_roundtrip(target: Target): """A serialization roundtrip yields an equivalent object.""" string = target.to_json() From e3c521c0de347a9a86514ebdcba3040c859305dd Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Tue, 2 Apr 2024 11:26:42 +0200 Subject: [PATCH 02/11] Add objective hypothesis strategies and roundtrip tests --- tests/hypothesis_strategies/objectives.py | 29 +++++++++++++++++++ .../test_objective_serialization.py | 23 +++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 tests/hypothesis_strategies/objectives.py diff --git a/tests/hypothesis_strategies/objectives.py b/tests/hypothesis_strategies/objectives.py new file mode 100644 index 000000000..ea79ed200 --- /dev/null +++ b/tests/hypothesis_strategies/objectives.py @@ -0,0 +1,29 @@ +"""Hypothesis strategies for objectives.""" + +import hypothesis.strategies as st + +from baybe.objectives.desirability import DesirabilityObjective +from baybe.objectives.enum import Scalarizer +from baybe.objectives.single import SingleTargetObjective + +from ..hypothesis_strategies.targets import targets as st_targets + + +def single_target_objectives(): + """Generate :class:`baybe.objectives.single.SingleTargetObjective`.""" + return st.builds(SingleTargetObjective, target=st_targets) + + +@st.composite +def desirability_objectives(draw: st.DrawFn): + """Generate :class:`baybe.objectives.desirability.DesirabilityObjective`.""" + targets = draw(st.lists(st_targets, min_size=2)) + weights = draw( + st.lists( + st.floats(min_value=0.0, exclude_min=True), + min_size=len(targets), + max_size=len(targets), + ) + ) + scalarizer = draw(st.sampled_from(Scalarizer)) + return DesirabilityObjective(targets, weights, scalarizer) diff --git a/tests/serialization/test_objective_serialization.py b/tests/serialization/test_objective_serialization.py index 78c238172..77e649e53 100644 --- a/tests/serialization/test_objective_serialization.py +++ b/tests/serialization/test_objective_serialization.py @@ -1,9 +1,28 @@ """Test serialization of objectives.""" +import hypothesis.strategies as st +import pytest +from hypothesis import given +from pytest import param + from baybe.objectives.base import Objective +from tests.hypothesis_strategies.objectives import ( + desirability_objectives, + single_target_objectives, +) -def test_objective_serialization(objective): +@pytest.mark.parametrize( + "objective_strategy", + [ + param(single_target_objectives(), id="SingleTargetObjective"), + param(desirability_objectives(), id="DesirabilityObjective"), + ], +) +@given(data=st.data()) +def test_objective_roundtrip(objective_strategy, data): + """A serialization roundtrip yields an equivalent object.""" + objective = data.draw(objective_strategy) string = objective.to_json() objective2 = Objective.from_json(string) - assert objective == objective2 + assert objective == objective2, (objective, objective2) From 4516da6392fe5bcef450ad55c6ac3a77634a1674 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Tue, 9 Apr 2024 08:01:29 +0200 Subject: [PATCH 03/11] Fix targets attribute/property --- baybe/objectives/desirability.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/baybe/objectives/desirability.py b/baybe/objectives/desirability.py index 19302b78c..34b6795f5 100644 --- a/baybe/objectives/desirability.py +++ b/baybe/objectives/desirability.py @@ -83,9 +83,10 @@ def scalarize( class DesirabilityObjective(Objective): """An objective scalarizing multiple targets using desirability values.""" - targets: tuple[Target, ...] = field( + _targets: tuple[Target, ...] = field( converter=to_tuple, validator=[min_len(2), deep_iterable(member_validator=instance_of(Target))], # type: ignore[type-abstract] + alias="targets", ) "The targets considered by the objective." @@ -101,7 +102,7 @@ def _default_weights(self) -> tuple[float, ...]: """Create unit weights for all targets.""" return tuple(1.0 for _ in range(len(self.targets))) - @targets.validator + @_targets.validator def _validate_targets(self, _, targets) -> None: # noqa: DOC101, DOC103 if not _is_all_numerical_targets(targets): raise TypeError( @@ -123,6 +124,11 @@ def _validate_weights(self, _, weights) -> None: # noqa: DOC101, DOC103 f"Specified number of targets: {lt}. Specified number of weights: {lw}." ) + @property + def targets(self) -> tuple[Target, ...]: # noqa: D102 + # See base class. + return self._targets + def __str__(self) -> str: start_bold = "\033[1m" end_bold = "\033[0m" From 2722b53e39b8fb9d74798b8fc8bc98317bb6ffd1 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Tue, 9 Apr 2024 08:10:01 +0200 Subject: [PATCH 04/11] Make targets bounded in desirability strategy --- tests/hypothesis_strategies/objectives.py | 8 +++++--- tests/hypothesis_strategies/targets.py | 10 +++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/hypothesis_strategies/objectives.py b/tests/hypothesis_strategies/objectives.py index ea79ed200..d04360163 100644 --- a/tests/hypothesis_strategies/objectives.py +++ b/tests/hypothesis_strategies/objectives.py @@ -6,18 +6,20 @@ from baybe.objectives.enum import Scalarizer from baybe.objectives.single import SingleTargetObjective -from ..hypothesis_strategies.targets import targets as st_targets +from ..hypothesis_strategies.targets import numerical_targets +from ..hypothesis_strategies.utils import intervals as st_intervals def single_target_objectives(): """Generate :class:`baybe.objectives.single.SingleTargetObjective`.""" - return st.builds(SingleTargetObjective, target=st_targets) + return st.builds(SingleTargetObjective, target=numerical_targets()) @st.composite def desirability_objectives(draw: st.DrawFn): """Generate :class:`baybe.objectives.desirability.DesirabilityObjective`.""" - targets = draw(st.lists(st_targets, min_size=2)) + intervals = st_intervals(exclude_fully_unbounded=True, exclude_half_bounded=True) + targets = draw(st.lists(numerical_targets(intervals), min_size=2)) weights = draw( st.lists( st.floats(min_value=0.0, exclude_min=True), diff --git a/tests/hypothesis_strategies/targets.py b/tests/hypothesis_strategies/targets.py index 242f1fffe..c62c68f0d 100644 --- a/tests/hypothesis_strategies/targets.py +++ b/tests/hypothesis_strategies/targets.py @@ -5,22 +5,22 @@ from baybe.targets.enum import TargetMode from baybe.targets.numerical import _VALID_TRANSFORMATIONS, NumericalTarget -from .utils import intervals +from .utils import intervals as st_intervals target_name = st.text(min_size=1) """A strategy that generates target names.""" @st.composite -def numerical_targets(draw: st.DrawFn): +def numerical_targets(draw: st.DrawFn, intervals=None): """Generate :class:`baybe.targets.numerical.NumericalTarget`.""" name = draw(target_name) mode = draw(st.sampled_from(TargetMode)) - bounds = draw( - intervals( + if intervals is None: + intervals = st_intervals( exclude_half_bounded=True, exclude_fully_unbounded=mode is TargetMode.MATCH ) - ) + bounds = draw(intervals) transformation = draw(st.sampled_from(_VALID_TRANSFORMATIONS[mode])) return NumericalTarget( From 2e71fdc7f0d6f48a06026ceec037210de8f10e7e Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Tue, 9 Apr 2024 08:15:21 +0200 Subject: [PATCH 05/11] Refactor weights attribute Normalizing the attribute during initialization causes roundtrip serialization to fail due to floating point inaccuracies. Thus, we store the raw weights and normalize only when required. --- baybe/objectives/base.py | 5 ++++- baybe/objectives/desirability.py | 27 +++++++++++++++------------ baybe/objectives/single.py | 2 +- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/baybe/objectives/base.py b/baybe/objectives/base.py index be2e11212..f6b804024 100644 --- a/baybe/objectives/base.py +++ b/baybe/objectives/base.py @@ -14,8 +14,11 @@ from baybe.serialization.mixin import SerialMixin from baybe.targets.base import Target +# TODO: Reactive slots in all classes once cached_property is supported: +# https://github.com/python-attrs/attrs/issues/164 -@define(frozen=True) + +@define(frozen=True, slots=False) class Objective(ABC, SerialMixin): """Abstract base class for all objectives.""" diff --git a/baybe/objectives/desirability.py b/baybe/objectives/desirability.py index 34b6795f5..2c49c34c1 100644 --- a/baybe/objectives/desirability.py +++ b/baybe/objectives/desirability.py @@ -1,7 +1,7 @@ """Functionality for desirability objectives.""" from collections.abc import Sequence -from functools import partial +from functools import cached_property, partial from typing import Callable import cattrs @@ -9,7 +9,7 @@ import numpy.typing as npt import pandas as pd from attrs import define, field -from attrs.validators import deep_iterable, instance_of, min_len +from attrs.validators import deep_iterable, gt, instance_of, min_len from typing_extensions import TypeGuard from baybe.objectives.base import Objective @@ -21,20 +21,15 @@ def _normalize_weights(weights: Sequence[float]) -> tuple[float, ...]: - """Normalize a collection of weights such that they sum to 1. + """Normalize a collection of (non-negative) weights such that they sum to 1. Args: weights: The un-normalized weights. - Raises: - ValueError: If any of the weights is non-positive. - Returns: The normalized weights. """ - array = np.asarray(cattrs.structure(weights, tuple[float, ...])) - if not np.all(array > 0.0): - raise ValueError("All weights must be strictly positive.") + array = np.asarray(weights) return tuple(array / array.sum()) @@ -79,7 +74,7 @@ def scalarize( return func(values, weights=weights) -@define(frozen=True) +@define(frozen=True, slots=False) class DesirabilityObjective(Objective): """An objective scalarizing multiple targets using desirability values.""" @@ -90,7 +85,10 @@ class DesirabilityObjective(Objective): ) "The targets considered by the objective." - weights: tuple[float, ...] = field(converter=_normalize_weights) + weights: tuple[float, ...] = field( + converter=lambda w: cattrs.structure(w, tuple[float, ...]), + validator=deep_iterable(member_validator=gt(0.0)), + ) """The weights to balance the different targets. By default, all targets are considered equally important.""" @@ -129,6 +127,11 @@ def targets(self) -> tuple[Target, ...]: # noqa: D102 # See base class. return self._targets + @cached_property + def _normalized_weights(self) -> tuple[float, ...]: + """The normalized target weights.""" + return _normalize_weights(self._weights) + def __str__(self) -> str: start_bold = "\033[1m" end_bold = "\033[0m" @@ -153,7 +156,7 @@ def transform(self, data: pd.DataFrame) -> pd.DataFrame: # noqa: D102 transformed[target.name] = target.transform(data[[target.name]]) # Scalarize the transformed targets into desirability values - vals = scalarize(transformed.values, self.scalarizer, self.weights) + vals = scalarize(transformed.values, self.scalarizer, self._normalized_weights) # Store the total desirability in a dataframe column transformed = pd.DataFrame({"Desirability": vals}, index=transformed.index) diff --git a/baybe/objectives/single.py b/baybe/objectives/single.py index 9c713b55d..50266fe39 100644 --- a/baybe/objectives/single.py +++ b/baybe/objectives/single.py @@ -8,7 +8,7 @@ from baybe.targets.base import Target -@define(frozen=True) +@define(frozen=True, slots=False) class SingleTargetObjective(Objective): """An objective focusing on a single target.""" From f2e69df053ed7fcfc6440312e358d33f2ad731a5 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Tue, 9 Apr 2024 08:22:10 +0200 Subject: [PATCH 06/11] Move weight normalization code into property --- baybe/objectives/desirability.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/baybe/objectives/desirability.py b/baybe/objectives/desirability.py index 2c49c34c1..79424422f 100644 --- a/baybe/objectives/desirability.py +++ b/baybe/objectives/desirability.py @@ -1,6 +1,5 @@ """Functionality for desirability objectives.""" -from collections.abc import Sequence from functools import cached_property, partial from typing import Callable @@ -20,19 +19,6 @@ from baybe.utils.numerical import geom_mean -def _normalize_weights(weights: Sequence[float]) -> tuple[float, ...]: - """Normalize a collection of (non-negative) weights such that they sum to 1. - - Args: - weights: The un-normalized weights. - - Returns: - The normalized weights. - """ - array = np.asarray(weights) - return tuple(array / array.sum()) - - def _is_all_numerical_targets( x: tuple[Target, ...], / ) -> TypeGuard[tuple[NumericalTarget, ...]]: @@ -128,9 +114,9 @@ def targets(self) -> tuple[Target, ...]: # noqa: D102 return self._targets @cached_property - def _normalized_weights(self) -> tuple[float, ...]: + def _normalized_weights(self) -> np.ndarray: """The normalized target weights.""" - return _normalize_weights(self._weights) + return np.asarray(self.weights) / np.sum(self.weights) def __str__(self) -> str: start_bold = "\033[1m" From b878661855ca844e9123032e0cab3192a264333c Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Tue, 9 Apr 2024 08:47:26 +0200 Subject: [PATCH 07/11] Update CHANGELOG.md --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb20e3b3d..809097711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Hypothesis strategies for acquisition functions - `Kernel` base class allowing to specify kernels - `MaternKernel` class can be chosen for GP surrogates -- `hypothesis` strategies and roundtrip test for kernels +- `hypothesis` strategies and roundtrip test for kernels and objectives ### Changed - `torch` numeric types are now loaded lazily @@ -171,7 +171,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Wrong use of `tolerance` argument in constraints user guide - Errors with generics and type aliases in documentation -- Deduplication bug in substance_data hypothesis +- Deduplication bug in substance_data `hypothesis` strategy - Use pydoclint as flake8 plugin and not as a stand-alone linter - Margins in documentation for desktop and mobile version - `Interval`s can now also be deserialized from a bounds iterable From d37f6746aea59af0805f2abd890233499ae6d049 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Tue, 9 Apr 2024 10:19:51 +0200 Subject: [PATCH 08/11] Add validation of target names --- baybe/objectives/desirability.py | 2 ++ tests/hypothesis_strategies/objectives.py | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/baybe/objectives/desirability.py b/baybe/objectives/desirability.py index 79424422f..f60704e3d 100644 --- a/baybe/objectives/desirability.py +++ b/baybe/objectives/desirability.py @@ -93,6 +93,8 @@ def _validate_targets(self, _, targets) -> None: # noqa: DOC101, DOC103 f"'{self.__class__.__name__}' currently only supports targets " f"of type '{NumericalTarget.__name__}'." ) + if len({t.name for t in targets}) != len(targets): + raise ValueError("All target names must be unique.") if not all(target._is_transform_normalized for target in targets): raise ValueError( "All targets must have normalized computational representations to " diff --git a/tests/hypothesis_strategies/objectives.py b/tests/hypothesis_strategies/objectives.py index d04360163..f0c1e5e74 100644 --- a/tests/hypothesis_strategies/objectives.py +++ b/tests/hypothesis_strategies/objectives.py @@ -19,7 +19,9 @@ def single_target_objectives(): def desirability_objectives(draw: st.DrawFn): """Generate :class:`baybe.objectives.desirability.DesirabilityObjective`.""" intervals = st_intervals(exclude_fully_unbounded=True, exclude_half_bounded=True) - targets = draw(st.lists(numerical_targets(intervals), min_size=2)) + targets = draw( + st.lists(numerical_targets(intervals), min_size=2, unique_by=lambda t: t.name) + ) weights = draw( st.lists( st.floats(min_value=0.0, exclude_min=True), From a66a79b71cf0e2a02d962dd290352354791f1fd6 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Tue, 9 Apr 2024 10:20:49 +0200 Subject: [PATCH 09/11] Add objective validation tests --- tests/validation/test_objective_validation.py | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/validation/test_objective_validation.py diff --git a/tests/validation/test_objective_validation.py b/tests/validation/test_objective_validation.py new file mode 100644 index 000000000..660704f75 --- /dev/null +++ b/tests/validation/test_objective_validation.py @@ -0,0 +1,77 @@ +"""Validation tests for objective.""" + +from unittest.mock import Mock + +import pytest +from cattrs import IterableValidationError +from pytest import param + +from baybe.objectives.desirability import DesirabilityObjective +from baybe.objectives.single import SingleTargetObjective +from baybe.targets.base import Target +from baybe.targets.numerical import NumericalTarget + +t1 = NumericalTarget("t1", mode="MAX", bounds=(0, 1)) +t2 = NumericalTarget("t2", mode="MAX", bounds=(0, 1)) +t3 = NumericalTarget("unnormalized", mode="MAX") +t_mock = Mock(spec=Target) + + +# +@pytest.mark.parametrize( + "target", + [ + param(None, id="none"), + param("no_target", id="string"), + ], +) +def test_invalid_target(target): + """Providing an invalid target object raises an exception.""" + with pytest.raises(TypeError): + SingleTargetObjective(target) + + +@pytest.mark.parametrize( + ("targets", "error"), + [ + param(None, TypeError, id="none"), + param([t1, "t2"], TypeError, id="wrong_type"), + param([t1], ValueError, id="too_short"), + param([t1, t1], ValueError, id="duplicate_names"), + param([t1, t3], ValueError, id="unnormalized"), + param([t1, t_mock], TypeError, id="unsupported_subclass"), + ], +) +def test_invalid_targets(targets, error): + """Providing invalid target objects raises an exception.""" + with pytest.raises(error): + DesirabilityObjective(targets) + + +@pytest.mark.parametrize( + ("weights", "error"), + [ + param(None, TypeError, id="none"), + param([1.0, "abc"], IterableValidationError, id="wrong_type"), + param([1.0, -1.0], ValueError, id="negative"), + param([1.0, 0.0], ValueError, id="zero"), + param([1.0], ValueError, id="wrong_length"), + ], +) +def test_invalid_weights(weights, error): + """Providing invalid weights raises an exception.""" + with pytest.raises(error): + DesirabilityObjective([t1, t2], weights) + + +@pytest.mark.parametrize( + "scalarizer", + [ + param(None, id="none"), + param("invalid", id="non_existing"), + ], +) +def test_invalid_scalarizer(scalarizer): + """Providing an invalid scalarizer raises an exception.""" + with pytest.raises(ValueError): + DesirabilityObjective([t1, t2], scalarizer=scalarizer) From e65e3d2cb24dbf61bab5038e29228bf33fa7ffed Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Tue, 9 Apr 2024 15:57:01 +0200 Subject: [PATCH 10/11] Make interval hypothesis strategy more efficient --- tests/hypothesis_strategies/utils.py | 64 +++++++++++++++++++++------- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/tests/hypothesis_strategies/utils.py b/tests/hypothesis_strategies/utils.py index 17ed660bd..53e8905db 100644 --- a/tests/hypothesis_strategies/utils.py +++ b/tests/hypothesis_strategies/utils.py @@ -1,11 +1,21 @@ """Hypothesis strategies for generating utility objects.""" +from enum import Enum, auto + +import hypothesis.extra.numpy as hnp import hypothesis.strategies as st -from hypothesis import assume from baybe.utils.interval import Interval +class IntervalType(Enum): + """The possible types of an interval on the real number line.""" + + FULLY_UNBOUNDED = auto() + HALF_BOUNDED = auto() + BOUNDED = auto() + + @st.composite def intervals( draw: st.DrawFn, @@ -19,18 +29,40 @@ def intervals( (exclude_bounded, exclude_half_bounded, exclude_fully_unbounded) ), "At least one Interval type must be allowed." - # Create interval from ordered pair of floats - bounds = ( - st.tuples(st.floats(), st.floats()).map(sorted).filter(lambda x: x[0] < x[1]) - ) - interval = Interval.create(draw(bounds)) - - # Filter excluded intervals - if exclude_bounded: - assume(not interval.is_bounded) - if exclude_half_bounded: - assume(not interval.is_half_bounded) - if exclude_fully_unbounded: - assume(not interval.is_fully_unbounded) - - return interval + # Draw the interval type from the allowed types + type_gate = { + IntervalType.FULLY_UNBOUNDED: not exclude_fully_unbounded, + IntervalType.HALF_BOUNDED: not exclude_half_bounded, + IntervalType.BOUNDED: not exclude_bounded, + } + allowed_types = [t for t, b in type_gate.items() if b] + interval_type = draw(st.sampled_from(allowed_types)) + + # A strategy producing finite floats + ffloats = st.floats(allow_infinity=False, allow_nan=False) + + # Draw the bounds depending on the interval type + if interval_type is IntervalType.FULLY_UNBOUNDED: + bounds = (None, None) + elif interval_type is IntervalType.HALF_BOUNDED: + bounds = draw( + st.sampled_from( + [ + (None, draw(ffloats)), + (draw(ffloats), None), + ] + ) + ) + elif interval_type is IntervalType.BOUNDED: + bounds = draw( + hnp.arrays( + dtype=float, + shape=(2,), + elements=ffloats, + unique=True, + ).map(sorted) + ) + else: + raise RuntimeError("This line should be unreachable.") + + return Interval.create(bounds) From 05248e141fb4d1d05bf48c2ad037d6a5b6f1497c Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 11 Apr 2024 14:24:32 +0200 Subject: [PATCH 11/11] Add type hint and docstring to numerical_targets strategy --- tests/hypothesis_strategies/targets.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/tests/hypothesis_strategies/targets.py b/tests/hypothesis_strategies/targets.py index c62c68f0d..eebbcb11a 100644 --- a/tests/hypothesis_strategies/targets.py +++ b/tests/hypothesis_strategies/targets.py @@ -1,9 +1,12 @@ """Hypothesis strategies for targets.""" +from typing import Optional + import hypothesis.strategies as st from baybe.targets.enum import TargetMode from baybe.targets.numerical import _VALID_TRANSFORMATIONS, NumericalTarget +from baybe.utils.interval import Interval from .utils import intervals as st_intervals @@ -12,15 +15,25 @@ @st.composite -def numerical_targets(draw: st.DrawFn, intervals=None): - """Generate :class:`baybe.targets.numerical.NumericalTarget`.""" +def numerical_targets( + draw: st.DrawFn, bounds_strategy: Optional[st.SearchStrategy[Interval]] = None +): + """Generate :class:`baybe.targets.numerical.NumericalTarget`. + + Args: + draw: Hypothesis draw object. + bounds_strategy: An optional strategy for generating the target bounds. + + Returns: + _type_: _description_ + """ name = draw(target_name) mode = draw(st.sampled_from(TargetMode)) - if intervals is None: - intervals = st_intervals( + if bounds_strategy is None: + bounds_strategy = st_intervals( exclude_half_bounded=True, exclude_fully_unbounded=mode is TargetMode.MATCH ) - bounds = draw(intervals) + bounds = draw(bounds_strategy) transformation = draw(st.sampled_from(_VALID_TRANSFORMATIONS[mode])) return NumericalTarget(