Skip to content

Commit

Permalink
Cardinality constraints for random recommender (#243)
Browse files Browse the repository at this point in the history
This PR is the first step on enabling cardinality constraints for
continuous subspaces:
- Adds `ContinuousNonlinearConstraint` and
`ContinuousCardinalityConstraint` classes
- Enables `ContinuousCardinalityConstraint` to be used when creating a
search space. More specifically, the sampling method in
`SubspaceContinuous` can now deal with both continuous cardinality
constraint as well as linear in-/equality constraints. This
automatically makes the random recommender compatible with continuous
cardinality constraints.
  • Loading branch information
AdrianSosic authored Jun 20, 2024
2 parents 3b9a44e + e76876e commit b7e6959
Show file tree
Hide file tree
Showing 20 changed files with 591 additions and 56 deletions.
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Validators for `Campaign` attributes
_ `_optional` subpackage for managing optional dependencies
- Acquisition function for active learning: `qNIPV`
- Abstract `ContinuousNonlinearConstraint` class
- `ContinuousCardinalityConstraint` class and corresponding uniform sampling mechanism

### Changed
- Passing an `Objective` to `Campaign` is now optional
- `GaussianProcessSurrogate` models are no longer wrapped when cast to BoTorch
- Restrict upper versions of main dependencies, motivated by major `numpy` release
- Sampling methods in `qNIPV` and `BotorchRecommender` are now specified via
`DiscreteSamplingMethod` enum
- `Interval` class now supports degenerate intervals containing only one element

### Removed
- Support for Python 3.9 removed due to new [BoTorch requirements](https://github.com/pytorch/botorch/pull/2293)
Expand All @@ -36,7 +39,10 @@ _ `_optional` subpackage for managing optional dependencies

### Deprecations
- `SequentialGreedyRecommender` class replaced with `BotorchRecommender`

- `SubspaceContinuous.samples_random` has been replaced with
`SubspaceContinuous.sample_uniform`
- `SubspaceContinuous.samples_full_factorial` has been replaced with
`SubspaceContinuous.sample_from_full_factorial`

## [0.9.1] - 2024-06-04
### Changed
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@
Documentation and general feedback
- Rim Rihana (Merck KGaA, Darmstadt, Germany):
Human readable output for search spaces
- Di Jin (Merck Life Science KGaA, Darmstadt, Germany)
Cardinality constraints
2 changes: 2 additions & 0 deletions baybe/constraints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from baybe.constraints.conditions import SubSelectionCondition, ThresholdCondition
from baybe.constraints.continuous import (
ContinuousCardinalityConstraint,
ContinuousLinearEqualityConstraint,
ContinuousLinearInequalityConstraint,
)
Expand All @@ -23,6 +24,7 @@
"SubSelectionCondition",
"ThresholdCondition",
# --- Continuous constraints ---#
"ContinuousCardinalityConstraint",
"ContinuousLinearEqualityConstraint",
"ContinuousLinearInequalityConstraint",
# --- Discrete constraints ---#
Expand Down
40 changes: 34 additions & 6 deletions baybe/constraints/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from collections.abc import Sequence
from collections.abc import Collection, Sequence
from typing import TYPE_CHECKING, Any, ClassVar

import numpy as np
Expand Down Expand Up @@ -107,11 +107,7 @@ def get_invalid(self, data: pd.DataFrame) -> pd.Index:

@define
class ContinuousConstraint(Constraint, ABC):
"""Abstract base class for continuous constraints.
Continuous constraints use parameter lists and coefficients to define in-/equality
constraints over a continuous parameter space.
"""
"""Abstract base class for continuous constraints."""

# class variables
eval_during_creation: ClassVar[bool] = False
Expand All @@ -120,6 +116,15 @@ class ContinuousConstraint(Constraint, ABC):
eval_during_modeling: ClassVar[bool] = True
# See base class.


@define
class ContinuousLinearConstraint(ContinuousConstraint, ABC):
"""Abstract base class for continuous linear constraints.
Continuous linear constraints use parameter lists and coefficients to define
in-/equality constraints over a continuous parameter space.
"""

# object variables
coefficients: list[float] = field()
"""In-/equality coefficient for each entry in ``parameters``."""
Expand Down Expand Up @@ -148,6 +153,25 @@ def _default_coefficients(self):
"""Return equal weight coefficients as default."""
return [1.0] * len(self.parameters)

def _drop_parameters(
self, parameter_names: Collection[str]
) -> ContinuousLinearConstraint:
"""Create a copy of the constraint with certain parameters removed.
Args:
parameter_names: The names of the parameter to be removed.
Returns:
The reduced constraint.
"""
parameters = [p for p in self.parameters if p not in parameter_names]
coefficients = [
c
for p, c in zip(self.parameters, self.coefficients)
if p not in parameter_names
]
return ContinuousLinearConstraint(parameters, coefficients, self.rhs)

def to_botorch(
self, parameters: Sequence[NumericalContinuousParameter], idx_offset: int = 0
) -> tuple[Tensor, Tensor, float]:
Expand Down Expand Up @@ -181,6 +205,10 @@ def to_botorch(
)


class ContinuousNonlinearConstraint(ContinuousConstraint, ABC):
"""Abstract base class for nonlinear constraints."""


# Register (un-)structure hooks
converter.register_unstructure_hook(Constraint, unstructure_base)
converter.register_structure_hook(Constraint, get_base_structure_hook(Constraint))
103 changes: 99 additions & 4 deletions baybe/constraints/continuous.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
"""Continuous constraints."""

from attr import define
import math

from baybe.constraints.base import ContinuousConstraint
import numpy as np
from attrs import define, field
from attrs.validators import ge, instance_of

from baybe.constraints.base import (
ContinuousLinearConstraint,
ContinuousNonlinearConstraint,
)


@define
class ContinuousLinearEqualityConstraint(ContinuousConstraint):
class ContinuousLinearEqualityConstraint(ContinuousLinearConstraint):
"""Class for continuous equality constraints.
The constraint is defined as ``sum_i[ x_i * c_i ] == rhs``, where x_i are the
Expand All @@ -19,7 +26,7 @@ class ContinuousLinearEqualityConstraint(ContinuousConstraint):


@define
class ContinuousLinearInequalityConstraint(ContinuousConstraint):
class ContinuousLinearInequalityConstraint(ContinuousLinearConstraint):
"""Class for continuous inequality constraints.
The constraint is defined as ``sum_i[ x_i * c_i ] >= rhs``, where x_i are the
Expand All @@ -31,3 +38,91 @@ class ContinuousLinearInequalityConstraint(ContinuousConstraint):
The class has no real content as it only serves the purpose of
distinguishing the constraints.
"""


@define
class ContinuousCardinalityConstraint(ContinuousNonlinearConstraint):
"""Class for continuous cardinality constraints.
Places a constraint on the set of nonzero (i.e. "active") values among the
specified parameters, bounding it between the two given integers,
``min_cardinality`` <= |{p_i : p_i != 0}| <= ``max_cardinality``
where ``{p_i}`` are the parameters specified for the constraint.
Note that this can be equivalently regarded as L0-constraint on the vector
containing the specified parameters.
"""

min_cardinality: int = field(default=0, validator=[instance_of(int), ge(0)])
"The minimum required cardinality."

max_cardinality: int = field(validator=instance_of(int))
"The maximum allowed cardinality."

@max_cardinality.default
def _default_max_cardinality(self):
"""Use the number of involved parameters as the upper limit by default."""
return len(self.parameters)

def __attrs_post_init__(self):
"""Validate the cardinality bounds.
Raises:
ValueError: If the provided cardinality bounds are invalid.
ValueError: If the provided cardinality bounds impose no constraint.
"""
if self.min_cardinality > self.max_cardinality:
raise ValueError(
f"The lower cardinality bound cannot be larger than the upper bound. "
f"Provided values: {self.max_cardinality=}, {self.min_cardinality=}."
)

if self.max_cardinality > len(self.parameters):
raise ValueError(
f"The cardinality bound cannot exceed the number of parameters. "
f"Provided values: {self.max_cardinality=}, {len(self.parameters)=}."
)

if self.min_cardinality == 0 and self.max_cardinality == len(self.parameters):
raise ValueError(
f"No constraint of type `{self.__class__.__name__}' is required "
f"when 0 <= cardinality <= len(parameters)."
)

def sample_inactive_parameters(self, batch_size: int = 1) -> list[set[str]]:
"""Sample sets of inactive parameters according to the cardinality constraints.
Args:
batch_size: The number of parameter sets to be sampled.
Returns:
A list of sampled inactive parameter sets, where each set holds the
corresponding parameter names.
"""
# The number of possible parameter configuration per set cardinality
n_configurations_per_cardinality = [
math.comb(len(self.parameters), n)
for n in range(self.min_cardinality, self.max_cardinality + 1)
]

# Probability of each set cardinality under the assumption that all possible
# inactive parameter sets are equally likely
probabilities = n_configurations_per_cardinality / np.sum(
n_configurations_per_cardinality
)

# Sample the number of active/inactive parameters
n_active_params = np.random.choice(
np.arange(self.min_cardinality, self.max_cardinality + 1),
batch_size,
p=probabilities,
)
n_inactive_params = len(self.parameters) - n_active_params

# Sample the inactive parameters
inactive_params = [
set(np.random.choice(self.parameters, n_inactive, replace=False))
for n_inactive in n_inactive_params
]

return inactive_params
29 changes: 29 additions & 0 deletions baybe/constraints/validation.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Validation functionality for constraints."""

from collections.abc import Collection
from itertools import combinations

from baybe.constraints.base import Constraint
from baybe.constraints.continuous import ContinuousCardinalityConstraint
from baybe.constraints.discrete import DiscreteDependenciesConstraint
from baybe.parameters.base import Parameter

Expand All @@ -15,6 +17,8 @@ def validate_constraints( # noqa: DOC101, DOC103
Raises:
ValueError: If there is more than one
:class:`baybe.constraints.discrete.DiscreteDependenciesConstraint` declared.
ValueError: If any two continuous cardinality constraints have an overlapping
parameter set.
ValueError: If any constraint contains an invalid parameter name.
ValueError: If any continuous constraint includes a discrete parameter.
ValueError: If any discrete constraint includes a continuous parameter.
Expand All @@ -25,6 +29,10 @@ def validate_constraints( # noqa: DOC101, DOC103
f"Please specify all dependencies in one single constraint."
)

validate_cardinality_constraints_are_nonoverlapping(
[con for con in constraints if isinstance(con, ContinuousCardinalityConstraint)]
)

param_names_all = [p.name for p in parameters]
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]
Expand Down Expand Up @@ -53,3 +61,24 @@ def validate_constraints( # noqa: DOC101, DOC103
f"that is continuous. Parameter list of the affected constraint: "
f"{constraint.parameters}"
)


def validate_cardinality_constraints_are_nonoverlapping(
constraints: Collection[ContinuousCardinalityConstraint]
) -> None:
"""Validate that cardinality constraints are non-overlapping.
Args:
constraints: A collection of continuous cardinality constraints.
Raises:
ValueError: If any two continuous cardinality constraints have an overlapping
parameter set.
"""
for c1, c2 in combinations(constraints, 2):
if (s1 := set(c1.parameters)).intersection(s2 := set(c2.parameters)):
raise ValueError(
f"Constraints of type `{ContinuousCardinalityConstraint.__name__}` "
f"cannot share the same parameters. Found the following overlapping "
f"parameter sets: {s1}, {s2}."
)
4 changes: 4 additions & 0 deletions baybe/parameters/numerical.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ def _validate_bounds(self, _: Any, value: Interval) -> None: # noqa: DOC101, DO
f"of {value.to_tuple()}. Infinite intervals for parameters are "
f"currently not supported."
)
if value.is_degenerate:
raise ValueError(
"The interval specified by the parameter bounds cannot be degenerate."
)

def is_in_range(self, item: float) -> bool: # noqa: D102
# See base class.
Expand Down
2 changes: 1 addition & 1 deletion baybe/recommenders/naive.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def recommend( # noqa: D102
# To make things simple, we sample a single point in the continuous space which
# will then be attached to every discrete point when the acquisition function
# is evaluated.
cont_part = searchspace.continuous.samples_random(1)
cont_part = searchspace.continuous.sample_uniform(1)
cont_part_tensor = to_tensor(cont_part).unsqueeze(-2)

# Get discrete candidates. The metadata flags are ignored since the search space
Expand Down
2 changes: 1 addition & 1 deletion baybe/recommenders/pure/nonpredictive/sampling.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def _recommend_hybrid(
if searchspace.type == SearchSpaceType.DISCRETE:
return candidates_comp.sample(batch_size)

cont_random = searchspace.continuous.samples_random(n_points=batch_size)
cont_random = searchspace.continuous.sample_uniform(batch_size=batch_size)
if searchspace.type == SearchSpaceType.CONTINUOUS:
return cont_random

Expand Down
Loading

0 comments on commit b7e6959

Please sign in to comment.