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 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 19302b78c..f60704e3d 100644 --- a/baybe/objectives/desirability.py +++ b/baybe/objectives/desirability.py @@ -1,7 +1,6 @@ """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 +8,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 @@ -20,24 +19,6 @@ from baybe.utils.numerical import geom_mean -def _normalize_weights(weights: Sequence[float]) -> tuple[float, ...]: - """Normalize a collection of 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.") - return tuple(array / array.sum()) - - def _is_all_numerical_targets( x: tuple[Target, ...], / ) -> TypeGuard[tuple[NumericalTarget, ...]]: @@ -79,17 +60,21 @@ 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.""" - 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." - 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.""" @@ -101,13 +86,15 @@ 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( 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 " @@ -123,6 +110,16 @@ 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 + + @cached_property + def _normalized_weights(self) -> np.ndarray: + """The normalized target weights.""" + return np.asarray(self.weights) / np.sum(self.weights) + def __str__(self) -> str: start_bold = "\033[1m" end_bold = "\033[0m" @@ -147,7 +144,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.""" 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/objectives.py b/tests/hypothesis_strategies/objectives.py new file mode 100644 index 000000000..f0c1e5e74 --- /dev/null +++ b/tests/hypothesis_strategies/objectives.py @@ -0,0 +1,33 @@ +"""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 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=numerical_targets()) + + +@st.composite +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, unique_by=lambda t: t.name) + ) + 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/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..eebbcb11a 100644 --- a/tests/hypothesis_strategies/targets.py +++ b/tests/hypothesis_strategies/targets.py @@ -1,26 +1,39 @@ """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 interval +from .utils import intervals as st_intervals target_name = st.text(min_size=1) """A strategy that generates target names.""" @st.composite -def numerical_target(draw: st.DrawFn): - """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)) - bounds = draw( - interval( + if bounds_strategy is None: + bounds_strategy = st_intervals( exclude_half_bounded=True, exclude_fully_unbounded=mode is TargetMode.MATCH ) - ) + bounds = draw(bounds_strategy) transformation = draw(st.sampled_from(_VALID_TRANSFORMATIONS[mode])) return NumericalTarget( @@ -28,5 +41,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..53e8905db 100644 --- a/tests/hypothesis_strategies/utils.py +++ b/tests/hypothesis_strategies/utils.py @@ -1,13 +1,23 @@ """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 interval( +def intervals( draw: st.DrawFn, *, exclude_bounded: bool = False, @@ -19,18 +29,40 @@ def interval( (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) 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) 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() 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)