Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Botorch with cardinality constraint via sampling #301

Open
wants to merge 109 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 91 commits
Commits
Show all changes
109 commits
Select commit Hold shift + click to select a range
d811240
Enable cardinality constraint in botorch recommender via sampling ina…
Waschenbacher Jul 2, 2024
da813f5
Make inactive parameters fixed features
Waschenbacher Jul 2, 2024
adf5cc2
Fix bug in test file
Waschenbacher Jul 4, 2024
e69ceff
Validate bounds of cardinality constraint parameters
Waschenbacher Jul 2, 2024
2270350
Add second option: iterate through combinatorial list
Waschenbacher Jul 3, 2024
6483e4b
Fix type error
Waschenbacher Jul 3, 2024
ae919d4
Revise botorch+cardinality constraint for enhanced clarity
Waschenbacher Jul 4, 2024
9ab8fda
Fix property names and its docstrings
Waschenbacher Jul 11, 2024
c3831c7
Use guard clause
Waschenbacher Jul 11, 2024
2f49f5a
Simplify syntax with 'prod'
Waschenbacher Jul 11, 2024
d46fd60
Refactor botorch+cardinality constraint
Waschenbacher Jul 12, 2024
293e2ef
Make 'n_threshold_inactive_parameters_generator' an attribute of boto…
Waschenbacher Jul 12, 2024
f8d0713
Refactor combinatorial properties of cardinality constraint
AdrianSosic Aug 15, 2024
76b5d72
Refactor combinatorial properties of continuous subspace
AdrianSosic Aug 15, 2024
5c92079
Refactor constraint validation
AdrianSosic Aug 15, 2024
f07a452
Move factory code up
AdrianSosic Aug 15, 2024
c5b014d
Simplify constructor code
AdrianSosic Aug 15, 2024
306c9d2
Update CHANGELOG.md
AdrianSosic Aug 15, 2024
7687e39
Ensure active parameters by altering parameters bounds
Waschenbacher Aug 23, 2024
66c3278
Fix continuous constraint test
Waschenbacher Aug 23, 2024
a1d11e7
Refactor botorch interface using fixed parameter class
Waschenbacher Aug 23, 2024
22fd942
Add try-except block to handle infeasible problem at certain inactive…
Waschenbacher Aug 26, 2024
120d717
Update CHANGELOG.md
Waschenbacher Aug 26, 2024
e6248b5
Fix type hint
Waschenbacher Aug 26, 2024
e0508b9
Fix test by repacing match text
Waschenbacher Aug 26, 2024
04d89d1
Refine docstrings
AdrianSosic Oct 15, 2024
102ef07
Fix method return type
AdrianSosic Oct 15, 2024
e357c95
Merge branch 'main' into feature/cardinality_constraint_to_botorch_vi…
AdrianSosic Oct 25, 2024
3d72f04
Fix capitalization in exception group
AdrianSosic Oct 25, 2024
ed8054b
Add explicit error handling to validator
AdrianSosic Oct 25, 2024
756bb09
Clean up cardinality constraint helper property
AdrianSosic Oct 25, 2024
b0c422e
Refactor parameter activation logic
AdrianSosic Oct 25, 2024
ddabcf5
Refactor method for enforcing cardinality constraints
AdrianSosic Oct 25, 2024
b8d24d7
Fix exception types and messages
AdrianSosic Oct 28, 2024
492bb3b
Apply minor formatting and documentation fixes
AdrianSosic Oct 28, 2024
584d8d9
Remove unnecessary `len` call
AdrianSosic Oct 28, 2024
5744e31
Remove unnecessary function layer
AdrianSosic Oct 28, 2024
e4afdcc
Extract loop into general function optimizing subspaces
AdrianSosic Oct 28, 2024
788a5ba
Simplify multi-space optimization logic
AdrianSosic Oct 29, 2024
046a8e2
Remove restriction on subspaces without cardinality constraints
AdrianSosic Oct 29, 2024
7aac4d3
Move __str__ method to top
AdrianSosic Oct 29, 2024
3c829d0
Rename threshold attribute
AdrianSosic Oct 29, 2024
bc697c0
Add item to README.md
AdrianSosic Oct 30, 2024
b9038b8
Implement summary method
AdrianSosic Oct 30, 2024
c2c8b99
Update CHANGELOG.md
AdrianSosic Nov 1, 2024
a721f21
Fix tests
AdrianSosic Nov 1, 2024
e84dda9
Explain mechanism of recommending with cardinality constraints
AdrianSosic Nov 1, 2024
fa13267
Add near-zero threshold to continuous numerical parameter
Waschenbacher Dec 13, 2024
7aa7c3f
Refine activate parameter helper function
Waschenbacher Dec 13, 2024
cfdf1e3
Show warnings when any minimum cardinality constraints are violated.
Waschenbacher Dec 14, 2024
e3c6620
Update test related to cardinality constraints
Waschenbacher Dec 15, 2024
b85924f
Add to-dos
Waschenbacher Dec 15, 2024
22b19f9
Add test on catching warning related to violation of minimum cardinal…
Waschenbacher Dec 16, 2024
b0dc037
Update CHANGELOG.md
Waschenbacher Dec 16, 2024
3fa8b02
Merge branch 'main' into feature/cardinality_constraint_to_botorch_vi…
Waschenbacher Dec 16, 2024
35825b8
Clean up merge conflict code
Waschenbacher Dec 16, 2024
3e275e4
Refine logic in counting the near-zero elements
Waschenbacher Dec 16, 2024
62f0ed6
Add TODO related to customized infeasibility error in botorch
Waschenbacher Jan 8, 2025
9af846b
Add threshold to continuous cardinality constraint
Waschenbacher Jan 8, 2025
10e0812
Adapt activate_parameter towards threshold per cardinality constraints
Waschenbacher Jan 8, 2025
142b1ec
Refine check cardinaltiy constraint fulfillment logic
Waschenbacher Jan 8, 2025
04f145c
Remove threshold related attribute and method in numerical continuous…
Waschenbacher Jan 9, 2025
55a7ba3
Make zero-checking and threshold definition compatible
Waschenbacher Jan 9, 2025
78b115f
Add activate parameter step in random sampler
Waschenbacher Jan 9, 2025
68045a7
Update CHANGELOG.md
Waschenbacher Jan 9, 2025
e6e2e97
Fix type hint in continuous numerical parameter classes
Waschenbacher Jan 9, 2025
a30b009
Test activate parameter function
Waschenbacher Jan 13, 2025
983b1a9
Correct logic on boundary handling in activate paramter
Waschenbacher Jan 13, 2025
bddab62
Ensure parameter bounds cover zero
Waschenbacher Jan 14, 2025
705cfb0
Decouple absolute threshold computation from parameter class
AdrianSosic Jan 17, 2025
ab77fc5
Refactor parameter activation utility
AdrianSosic Feb 10, 2025
3911159
Refactor parameter activation test
AdrianSosic Feb 10, 2025
86541ea
Add missing test case
AdrianSosic Feb 10, 2025
113f634
Move utils module to constraints subpackage
AdrianSosic Feb 10, 2025
83726cb
Refactor cardinality constraint utilities
AdrianSosic Feb 10, 2025
dd73a38
Move is_between to utils/dataframe
AdrianSosic Feb 10, 2025
e3d8a90
Refactor cardinality warning check
AdrianSosic Feb 11, 2025
79aa0b6
Fix docstring
AdrianSosic Feb 11, 2025
699b98c
Merge main branch
AdrianSosic Feb 11, 2025
b478970
Fix error handling in case of infeasibility
AdrianSosic Feb 11, 2025
5a1c26f
Consider other warning types in cardinality warning check
AdrianSosic Feb 11, 2025
f71b394
Assert that cardinality constraints are disjoint
AdrianSosic Feb 11, 2025
daa594a
Add temporary botorch workaround
AdrianSosic Feb 11, 2025
a14e6e1
Refine definition of inactive parameter interval
AdrianSosic Feb 11, 2025
1bfaf13
Activate parameters only in presence of minimum cardinality constraints
AdrianSosic Feb 11, 2025
0cc2e9f
Explain inactivity threshold logic in docstring
AdrianSosic Feb 11, 2025
2a449ca
Revert "Add temporary botorch workaround"
AdrianSosic Feb 11, 2025
1bbf605
Rename _enforce_cardinality_constraints_via_assignment
AdrianSosic Feb 12, 2025
9e776e5
Extend docstring to explain limit on number of subspaces
AdrianSosic Feb 12, 2025
2e37b3b
Add a recommended action to the cardinality error message
AdrianSosic Feb 12, 2025
207367f
Update CHANGELOG.md
AdrianSosic Feb 12, 2025
627698d
Drop unnecessary guard clause
AdrianSosic Feb 25, 2025
e0c5e68
Fix error message
AdrianSosic Feb 25, 2025
7d091c7
Change default threshold value from 1e-2 to 1e-3
AdrianSosic Feb 26, 2025
9cae97b
Refine error message
AdrianSosic Feb 26, 2025
430483b
Fix docstring
AdrianSosic Feb 26, 2025
d002e21
Catch more specific InfeasibilityError instead of generic ValueError
AdrianSosic Feb 26, 2025
8f2c98f
Refine function definition and docstring
AdrianSosic Feb 26, 2025
067b12e
Change return type from tuple to frozenset
AdrianSosic Feb 26, 2025
336efad
Move cardinality constraint test to dedicated module
AdrianSosic Feb 26, 2025
dd8078f
Drop blank line
AdrianSosic Feb 26, 2025
fa9c33c
Explain active parameters in function docstring
AdrianSosic Feb 26, 2025
2287c3d
Fix dropping of cardinality constraints
AdrianSosic Feb 26, 2025
96308ad
Refine changelog entries
AdrianSosic Feb 26, 2025
df181ad
Move assert statement out of context block
AdrianSosic Feb 27, 2025
710b6e7
Explain reason for minimum cardinality violation in warning
AdrianSosic Feb 27, 2025
509c81e
Add note explaining the explicit constraint conversion step
AdrianSosic Feb 27, 2025
50c019f
Fix return type annotation
AdrianSosic Feb 27, 2025
12e4421
Add suggestion to InfeasibilityError message
AdrianSosic Feb 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `BCUT2D` encoding for `SubstanceParameter`
- Stored benchmarking results now include the Python environment and version
- `qPSTD` acquisition function
- `ContinuousCardinalityConstraint` is now compatible with `BotorchRecommender`
- A `MinimumCardinalityViolatedWarning` is triggered when minimum cardinality
constraints are violated
- Attribute `max_n_subspaces` to `BotorchRecommender`, allowing to control
optimization behavior in the presence of cardinality constraints
- Utilities `inactive_parameter_combinations` and`n_inactive_parameter_combinations`
in both `ContinuousCardinalityConstraint`and `SubspaceContinuous`
- Attribute `relative_threshold` and method `get_absolute_thresholds` to
`ContinuousCardinalityConstraint`
- Utilities `activate_parameter`, `is_between` and `is_cardinality_fulfilled`

### Changed
- Acquisition function indicator `is_mc` has been removed in favor of new indicators
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ The following provides a non-comprehensive overview:
- 🎭 Hybrid (mixed continuous and discrete) spaces
- 🚀 Transfer learning: Mix data from multiple campaigns and accelerate optimization
- 🎰 Bandit models: Efficiently find the best among many options in noisy environments (e.g. A/B Testing)
- 🔢 Cardinality constraints: Control the number of active factors in your design
- 🌎 Distributed workflows: Run campaigns asynchronously with pending experiments
- 🎓 Active learning: Perform smart data acquisition campaigns
- ⚙️ Custom surrogate models: Enhance your predictions through mechanistic understanding
Expand Down
69 changes: 67 additions & 2 deletions baybe/constraints/continuous.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

import gc
import math
from collections.abc import Collection, Sequence
from collections.abc import Collection, Iterator, Sequence
from itertools import combinations
from math import comb
from typing import TYPE_CHECKING, Any

import numpy as np
from attr.validators import in_
from attr.validators import gt, in_, lt
from attrs import define, field

from baybe.constraints.base import (
Expand All @@ -17,6 +19,7 @@
ContinuousNonlinearConstraint,
)
from baybe.parameters import NumericalContinuousParameter
from baybe.utils.interval import Interval
from baybe.utils.numerical import DTypeFloatNumpy
from baybe.utils.validation import finite_float

Expand Down Expand Up @@ -138,6 +141,40 @@ class ContinuousCardinalityConstraint(
):
"""Class for continuous cardinality constraints."""

relative_threshold: float = field(
default=1e-2, converter=float, validator=[gt(0.0), lt(1.0)]
)
"""A relative threshold for determining if a value is considered zero.

The threshold is translated into an asymmetric open interval around zero via
:meth:`get_absolute_thresholds`.

**Note:** The interval induced by the threshold is considered **open** because
numerical routines that optimize parameter values on the complementary set (i.e. the
value range considered "nonzero") may push the numerical value exactly to the
interval boundary, which should therefore also be considered "nonzero".
"""

@property
def n_inactive_parameter_combinations(self) -> int:
"""The number of possible inactive parameter combinations."""
return sum(
comb(len(self.parameters), n_inactive_parameters)
for n_inactive_parameters in self._inactive_set_sizes()
)

def _inactive_set_sizes(self) -> range:
"""Get all possible sizes of inactive parameter sets."""
return range(
len(self.parameters) - self.max_cardinality,
len(self.parameters) - self.min_cardinality + 1,
)

def inactive_parameter_combinations(self) -> Iterator[frozenset[str]]:
"""Get an iterator over all possible combinations of inactive parameters."""
for n_inactive_parameters in self._inactive_set_sizes():
yield from combinations(self.parameters, n_inactive_parameters)

def sample_inactive_parameters(self, batch_size: int = 1) -> list[set[str]]:
"""Sample sets of inactive parameters according to the cardinality constraints.

Expand Down Expand Up @@ -176,6 +213,34 @@ def sample_inactive_parameters(self, batch_size: int = 1) -> list[set[str]]:

return inactive_params

def get_absolute_thresholds(self, bounds: Interval, /) -> Interval:
"""Get the absolute thresholds for a given interval.

Turns the relative threshold of the constraint into absolute thresholds
for the considered interval. That is, for a given interval ``(a, b)`` with
``a <= 0`` and ``b >= 0``, the method returns the interval ``(r*a, r*b)``,
where ``r`` is the relative threshold defined by the constraint.

Args:
bounds: The specified interval.

Returns:
The absolute thresholds represented as an interval.

Raises:
ValueError: When the specified interval does not contain zero.
"""
if not bounds.contains(0.0):
raise ValueError(
f"The specified interval must contain zero. "
f"Given: {bounds.to_tuple()}."
)

return Interval(
lower=self.relative_threshold * bounds.lower,
upper=self.relative_threshold * bounds.upper,
)


# Collect leftover original slotted classes processed by `attrs.define`
gc.collect()
50 changes: 50 additions & 0 deletions baybe/constraints/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Constraint utilities."""

import numpy as np
import pandas as pd

from baybe.searchspace import SubspaceContinuous
from baybe.utils.dataframe import is_between


def is_cardinality_fulfilled(
df: pd.DataFrame,
subspace_continuous: SubspaceContinuous,
*,
check_minimum: bool = True,
check_maximum: bool = True,
) -> bool:
"""Validate cardinality constraints in a dataframe of parameter configurations.

Args:
df: The dataframe to be checked.
subspace_continuous: The subspace spanned by the considered parameters.
check_minimum: If ``True``, minimum cardinality constraints are validated.
check_maximum: If ``True``, maximum cardinality constraints are validated.

Returns:
``True`` if all cardinality constraints are fulfilled, ``False`` otherwise.
"""
if len(subspace_continuous.constraints_cardinality) == 0:
return True

for c in subspace_continuous.constraints_cardinality:
# Get the activity thresholds for all parameters
thresholds = {
p.name: c.get_absolute_thresholds(p.bounds)
for p in subspace_continuous.get_parameters_by_name(c.parameters)
}

# Count the number of active values per dataframe row
cols = df[c.parameters]
is_inactive = is_between(cols, thresholds) | (cols == 0.0)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isnt the cols == 0.0 part of obsolete? its also checked by is_between since 0 is enforced to be inside the interval?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, not quite. There is a subtle issue because is_between gives False when the value is 0 and one of the bounds itself is zero (due to inequality check). Alternatively, we could resolve this by making is_between a true generalization of pandas.Series.is_between, which gives control on whether equality or inequality should be used. What do you prefer?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that would mak sene to me and would be prefereable, the current line here feels weird

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah no, my bad. The generalization does actually not solve the situation here. The special case was added for a different reason, namely to satisfy what is explained in the docstring of the cardinality constraint:
image
So is_between really needs to be invoked with < instead of <= to correctly count inactive parameters. Can you confirm that I'm not talking BS, @Waschenbacher?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synced with @AdrianSosic. We decide to rework on this part that should solve this issue as well as the other open thread here.

n_zeros = is_inactive.sum(axis=1)
n_active = len(c.parameters) - n_zeros

# Check if cardinality is violated
if check_minimum and np.any(n_active < c.min_cardinality):
return False
if check_maximum and np.any(n_active > c.max_cardinality):
return False

return True
63 changes: 63 additions & 0 deletions baybe/constraints/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@
from baybe.constraints.discrete import (
DiscreteDependenciesConstraint,
)
from baybe.parameters import NumericalContinuousParameter
from baybe.parameters.base import Parameter

try: # For python < 3.11, use the exceptiongroup backport
ExceptionGroup
except NameError:
from exceptiongroup import ExceptionGroup


def validate_constraints( # noqa: DOC101, DOC103
constraints: Collection[Constraint], parameters: Collection[Parameter]
Expand All @@ -26,6 +32,8 @@ def validate_constraints( # noqa: DOC101, DOC103
ValueError: If any discrete constraint includes a continuous parameter.
ValueError: If any discrete constraint that is valid only for numerical
discrete parameters includes non-numerical discrete parameters.
ValueError: If any parameter affected by a cardinality constraint does
not include zero.
"""
if sum(isinstance(itm, DiscreteDependenciesConstraint) for itm in constraints) > 1:
raise ValueError(
Expand All @@ -41,6 +49,9 @@ def validate_constraints( # noqa: DOC101, DOC103
param_names_discrete = [p.name for p in parameters if p.is_discrete]
param_names_continuous = [p.name for p in parameters if p.is_continuous]
param_names_non_numerical = [p.name for p in parameters if not p.is_numerical]
params_continuous: list[NumericalContinuousParameter] = [
p for p in parameters if isinstance(p, NumericalContinuousParameter)
]

for constraint in constraints:
if not all(p in param_names_all for p in constraint.parameters):
Expand Down Expand Up @@ -78,6 +89,11 @@ def validate_constraints( # noqa: DOC101, DOC103
f"Parameter list of the affected constraint: {constraint.parameters}."
)

if isinstance(constraint, ContinuousCardinalityConstraint):
validate_cardinality_constraint_parameter_bounds(
constraint, params_continuous
)


def validate_cardinality_constraints_are_nonoverlapping(
constraints: Collection[ContinuousCardinalityConstraint],
Expand All @@ -98,3 +114,50 @@ def validate_cardinality_constraints_are_nonoverlapping(
f"cannot share the same parameters. Found the following overlapping "
f"parameter sets: {s1}, {s2}."
)


def validate_cardinality_constraint_parameter_bounds(
constraint: ContinuousCardinalityConstraint,
parameters: Collection[NumericalContinuousParameter],
) -> None:
"""Validate that all parameters of a continuous cardinality constraint include zero.

Args:
constraint: A continuous cardinality constraint.
parameters: A collection of parameters, including those affected by the
constraint.

Raises:
ValueError: If one of the affected parameters does not include zero.
ExceptionGroup: If several of the affected parameters do not include zero.
"""
exceptions = []
for name in constraint.parameters:
try:
parameter = next(p for p in parameters if p.name == name)
except StopIteration as ex:
raise ValueError(
f"The parameter '{name}' referenced by the constraint is not contained "
f"in the given collection of parameters."
) from ex

if not parameter.is_in_range(0.0):
exceptions.append(
ValueError(
f"The bounds of all parameters affected by a constraint of type "
f"'{ContinuousCardinalityConstraint.__name__}' must include zero, "
f"but the bounds of parameter '{name}' are: "
f"{parameter.bounds.to_tuple()}, which may indicate unintended "
f"settings in your parameter definition. "
f"A parameter whose value range includes zero trivially "
f"increases the cardinality of the resulting configuration by one. "
f"Therefore, if your parameter definitions are all correct, "
f"consider excluding the parameter from the constraint and "
f"reducing the cardinality limits by one accordingly."
)
)

if exceptions:
if len(exceptions) == 1:
raise exceptions[0]
raise ExceptionGroup("Invalid parameter bounds", exceptions)
8 changes: 8 additions & 0 deletions baybe/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ class UnusedObjectWarning(UserWarning):
"""


class MinimumCardinalityViolatedWarning(UserWarning):
"""Minimum cardinality constraints are violated."""


##### Exceptions #####


Expand All @@ -37,6 +41,10 @@ class IncompatibleArgumentError(IncompatibilityError):
"""An incompatible argument was passed to a callable."""


class InfeasibilityError(Exception):
"""An optimization problem has no feasible solution."""


class NotEnoughPointsLeftError(Exception):
"""
More recommendations are requested than there are viable parameter configurations
Expand Down
35 changes: 34 additions & 1 deletion baybe/parameters/numerical.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def is_in_range(self, item: float) -> bool:

@override
@property
def comp_rep_columns(self) -> tuple[str, ...]:
def comp_rep_columns(self) -> tuple[str]:
return (self.name,)

@override
Expand All @@ -150,5 +150,38 @@ def summary(self) -> dict:
return param_dict


@define(frozen=True, slots=False)
class _FixedNumericalContinuousParameter(ContinuousParameter):
"""Parameter class for fixed numerical parameters."""

is_numeric: ClassVar[bool] = True
# See base class.

value: float = field(converter=float)
"""The fixed value of the parameter."""

@property
def bounds(self) -> Interval:
"""The value of the parameter as a degenerate interval."""
return Interval(self.value, self.value)

@override
def is_in_range(self, item: float) -> bool:
return item == self.value

@override
@property
def comp_rep_columns(self) -> tuple[str]:
return (self.name,)

@override
def summary(self) -> dict:
return dict(
Name=self.name,
Type=self.__class__.__name__,
Value=self.value,
)


# Collect leftover original slotted classes processed by `attrs.define`
gc.collect()
Loading