diff --git a/CHANGELOG.md b/CHANGELOG.md index 78626ec490..7451fa7a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `TransferLearningMode` enum) for selecting the kernel that models the task correlations in transfer learning, taking precedence over the task kernel of the configured kernel factory +- `DiscreteRepetitionConstraint` for controlling value repetition across parameters + via `n_max_repetitions` (replaces `DiscreteNoLabelDuplicatesConstraint` and + `DiscreteLinkedParametersConstraint`) ### Changed - `BOTORCH` GP preset now includes `BetaPrior(2.5, 1.5)` for the task covariance @@ -63,6 +66,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Deprecations - `DiscreteExcludeConstraint` in favor of `DiscreteSelectionConstraint(..., exclude=True)` +- `DiscreteNoLabelDuplicatesConstraint` in favor of + `DiscreteRepetitionConstraint(..., n_max_repetitions=1)` +- `DiscreteLinkedParametersConstraint` in favor of + `DiscreteRepetitionConstraint(..., n_max_repetitions=len(parameters)-1, + exclude=True)` ## [0.15.0] - 2026-06-11 ### Breaking Changes diff --git a/baybe/campaign.py b/baybe/campaign.py index 16fad7715c..3aa1d48273 100644 --- a/baybe/campaign.py +++ b/baybe/campaign.py @@ -17,7 +17,7 @@ from attrs.validators import instance_of from typing_extensions import override -from baybe.constraints.base import DiscreteConstraint, DiscreteFilteringConstraint +from baybe.constraints.base import DiscreteFilteringConstraint from baybe.exceptions import ( IncompatibilityError, NoMeasurementsError, @@ -426,7 +426,7 @@ def update_measurements( def toggle_discrete_candidates( # noqa: DOC501 self, - constraints: Collection[DiscreteConstraint] | pd.DataFrame, + constraints: Collection[DiscreteFilteringConstraint] | pd.DataFrame, exclude: bool, complement: bool = False, dry_run: bool = False, @@ -436,8 +436,8 @@ def toggle_discrete_candidates( # noqa: DOC501 Args: constraints: A filtering mechanism determining the candidates subset to be in-/excluded. Can be either a collection of - :class:`~baybe.constraints.base.DiscreteConstraint` or a dataframe. - For the latter, see :func:`~baybe.utils.dataframe.filter_df` + :class:`~baybe.constraints.base.DiscreteFilteringConstraint` or a + dataframe. For the latter, see :func:`~baybe.utils.dataframe.filter_df` for details. exclude: If ``True``, the specified candidates are excluded. If ``False``, the candidates are considered for recommendation. @@ -483,8 +483,8 @@ def toggle_discrete_candidates( # noqa: DOC501 else: raise TypeError( - "Candidate toggling is not implemented for the given type of " - "constraint specifications." + f"Candidate toggling requires a dataframe or a collection of " + f"'{DiscreteFilteringConstraint.__name__}' instances." ) if not dry_run: diff --git a/baybe/constraints/__init__.py b/baybe/constraints/__init__.py index 3402057576..7c468b0c51 100644 --- a/baybe/constraints/__init__.py +++ b/baybe/constraints/__init__.py @@ -16,6 +16,7 @@ DiscreteNoLabelDuplicatesConstraint, DiscretePermutationInvarianceConstraint, DiscreteProductConstraint, + DiscreteRepetitionConstraint, DiscreteSelectionConstraint, DiscreteSumConstraint, ) @@ -34,11 +35,12 @@ "DiscreteCustomConstraint", "DiscreteDependenciesConstraint", "DiscreteExcludeConstraint", - "DiscreteSelectionConstraint", "DiscreteLinkedParametersConstraint", "DiscreteNoLabelDuplicatesConstraint", "DiscretePermutationInvarianceConstraint", "DiscreteProductConstraint", + "DiscreteRepetitionConstraint", + "DiscreteSelectionConstraint", "DiscreteSumConstraint", # --- Other --- # "validate_constraints", diff --git a/baybe/constraints/base.py b/baybe/constraints/base.py index 7de65b38c2..0f1e1500e3 100644 --- a/baybe/constraints/base.py +++ b/baybe/constraints/base.py @@ -197,18 +197,21 @@ def has_polars_implementation(cls) -> bool: is not DiscreteFilteringConstraint._get_matching_rows_polars ) - def get_invalid_polars(self) -> pl.Expr: + def get_invalid_polars(self, schema: pl.Schema) -> pl.Expr: """Translate the constraint to a Polars expression identifying rows to remove. + Args: + schema: The Polars schema of the dataframe being filtered. + Returns: The Polars expression. """ - matching_expr = self._get_matching_rows_polars() + matching_expr = self._get_matching_rows_polars(schema) if self.exclude: return matching_expr return ~matching_expr - def _get_matching_rows_polars(self) -> pl.Expr: + def _get_matching_rows_polars(self, schema: pl.Schema) -> pl.Expr: """Translate the constraint to a Polars expression identifying matching rows. Subclasses with a Polars implementation override this method. The expression @@ -216,6 +219,9 @@ def _get_matching_rows_polars(self) -> pl.Expr: (as if ``exclude=False``). The ``exclude`` inversion is applied by the base class in :meth:`get_invalid_polars`, not here. + Args: + schema: The Polars schema of the dataframe being filtered. + Returns: A Polars expression that evaluates to ``True`` for matching rows. diff --git a/baybe/constraints/discrete.py b/baybe/constraints/discrete.py index 5fd836ad95..464a573749 100644 --- a/baybe/constraints/discrete.py +++ b/baybe/constraints/discrete.py @@ -11,8 +11,8 @@ import numpy as np import numpy.typing as npt import pandas as pd -from attrs import define, field -from attrs.validators import deep_iterable, in_, min_len +from attrs import define, field, fields +from attrs.validators import deep_iterable, ge, in_, instance_of, min_len from typing_extensions import override from baybe.constraints.base import ( @@ -23,6 +23,7 @@ ) from baybe.constraints.conditions import ( Condition, + SubSelectionCondition, # noqa: F401 (used in doctests) ThresholdCondition, _threshold_operators, _valid_logic_combiners, @@ -49,13 +50,13 @@ def DiscreteExcludeConstraint( # noqa: N802 conditions: list[Condition], combiner: str = "AND", ) -> DiscreteSelectionConstraint: - """A ``DiscreteSelectionConstraint`` alias for backward compatibility.""" # noqa: D401 + """A :class:`DiscreteSelectionConstraint` alias for backward compatibility.""" # noqa: D401 import warnings warnings.warn( f"'{DiscreteExcludeConstraint.__name__}' is deprecated and will be removed " f"in a future version. Use '{DiscreteSelectionConstraint.__name__}' with " - f"'exclude=True' instead.", + f"'{fields(DiscreteSelectionConstraint).exclude.alias}=True' instead.", DeprecationWarning, stacklevel=2, ) @@ -72,7 +73,30 @@ def DiscreteExcludeConstraint( # noqa: N802 @define class DiscreteSelectionConstraint(DiscreteFilteringConstraint): - """Class for filtering search space entries based on conditions.""" + """Class for filtering search space entries based on conditions. + + Examples: + >>> df = pd.DataFrame({ + ... "Solvent": ["Water", "Water", "Hexane", "Hexane"], + ... "Temp": [80.0, 120.0, 80.0, 120.0], + ... }) + >>> df + Solvent Temp + 0 Water 80.0 + 1 Water 120.0 + 2 Hexane 80.0 + 3 Hexane 120.0 + >>> c = DiscreteSelectionConstraint( + ... parameters=["Solvent", "Temp"], + ... conditions=[ + ... SubSelectionCondition(selection=["Hexane"]), + ... ThresholdCondition(threshold=100.0, operator=">="), + ... ], + ... exclude=True, + ... ) + >>> list(c.get_invalid(df)) + [3] + """ # object variables conditions: list[Condition] = field(validator=min_len(1)) @@ -111,7 +135,7 @@ def _get_matching_rows(self, df: pd.DataFrame, /) -> pd.Index: return df.index[res] @override - def _get_matching_rows_polars(self) -> pl.Expr: + def _get_matching_rows_polars(self, schema: pl.Schema) -> pl.Expr: from baybe._optional.polars import polars as pl satisfied = [] @@ -127,6 +151,30 @@ class DiscreteSumConstraint(DiscreteFilteringConstraint): The constraint evaluates whether the (optionally weighted) sum of the specified parameters satisfies the given threshold condition. + + Examples: + >>> df = pd.DataFrame({"A": [1.0, 3.0, 5.0], "B": [2.0, 1.0, 3.0]}) + >>> df + A B + 0 1.0 2.0 + 1 3.0 1.0 + 2 5.0 3.0 + >>> c = DiscreteSumConstraint( + ... parameters=["A", "B"], + ... condition=ThresholdCondition(threshold=5.0, operator="<="), + ... ) + >>> list(c.get_invalid(df)) + [2] + + With coefficients, the weighted sum is checked instead: + + >>> c = DiscreteSumConstraint( + ... parameters=["A", "B"], + ... condition=ThresholdCondition(threshold=5.0, operator="<="), + ... coefficients=(2.0, 1.0), + ... ) + >>> list(c.get_invalid(df)) + [1, 2] """ # IMPROVE: refactor `SumConstraint` and `ProdConstraint` to avoid code copying @@ -188,7 +236,7 @@ def _get_matching_rows(self, df: pd.DataFrame, /) -> pd.Index: return df.index[mask_good] @override - def _get_matching_rows_polars(self) -> pl.Expr: + def _get_matching_rows_polars(self, schema: pl.Schema) -> pl.Expr: from baybe._optional.polars import polars as pl weighted = [pl.col(p) * c for p, c in zip(self.parameters, self.coefficients)] @@ -197,7 +245,22 @@ def _get_matching_rows_polars(self) -> pl.Expr: @define class DiscreteProductConstraint(DiscreteFilteringConstraint): - """Class for modelling product constraints.""" + """Class for modelling product constraints. + + Examples: + >>> df = pd.DataFrame({"A": [2.0, 3.0, 5.0], "B": [3.0, 2.0, 2.0]}) + >>> df + A B + 0 2.0 3.0 + 1 3.0 2.0 + 2 5.0 2.0 + >>> c = DiscreteProductConstraint( + ... parameters=["A", "B"], + ... condition=ThresholdCondition(threshold=8.0, operator="<="), + ... ) + >>> list(c.get_invalid(df)) + [2] + """ # IMPROVE: refactor `SumConstraint` and `ProdConstraint` to avoid code copying @@ -222,7 +285,7 @@ def _get_matching_rows(self, df: pd.DataFrame, /) -> pd.Index: return df.index[mask_good] @override - def _get_matching_rows_polars(self) -> pl.Expr: + def _get_matching_rows_polars(self, schema: pl.Schema) -> pl.Expr: from baybe._optional.polars import polars as pl op = _threshold_operators[self.condition.operator] @@ -234,84 +297,164 @@ def _get_matching_rows_polars(self) -> pl.Expr: return op(expr, self.condition.threshold) -class DiscreteNoLabelDuplicatesConstraint(DiscreteFilteringConstraint): - """Constraint class for keeping entries where all labels are unique. +@define +class DiscreteRepetitionConstraint(DiscreteFilteringConstraint): + """Class for constraining value repetition across parameters. + + Keeps only rows where no single value appears more than a specified number of + times across the specified parameters. + + Examples: + >>> df = pd.DataFrame({"A": ["x", "y", "x"], "B": ["y", "x", "x"]}) + >>> df + A B + 0 x y + 1 y x + 2 x x + + Upper bound: row 2 has "x" twice, violating ``n_max_repetitions=1``: + + >>> c = DiscreteRepetitionConstraint( + ... parameters=["A", "B"], n_max_repetitions=1 + ... ) + >>> list(c.get_invalid(df)) + [2] + + With ``exclude=True``, the logic inverts and only repeated rows are kept: + + >>> c = DiscreteRepetitionConstraint( + ... parameters=["A", "B"], n_max_repetitions=1, exclude=True + ... ) + >>> list(c.get_invalid(df)) + [0, 1] + """ + + # object variables + n_max_repetitions: int = field( + default=1, validator=[instance_of(int), ge(1)], kw_only=True + ) + """Maximum number of times any single value may appear in a row.""" - This can be useful to remove entries that arise from e.g. a permutation invariance - as for instance here: + def __attrs_post_init__(self) -> None: + """Validate the maximum repetition count. - - A,B,C,D would be kept - - A,A,B,C would be removed - - A,A,B,B would be removed - - A,A,B,A would be removed - - A,C,A,C would be removed - - A,C,B,C would be removed - """ + Raises: + ValueError: If the maximum repetition count imposes no meaningful + constraint. + """ + n_params = len(self.parameters) + if self.n_max_repetitions >= n_params: + raise ValueError( + f"'{fields(type(self)).n_max_repetitions.alias}' must be less than " + f"the number of parameters ({n_params}) to impose a meaningful " + f"constraint, but got {self.n_max_repetitions}." + ) @override def _can_evaluate(self, available: set[str], /) -> bool: - # exclude=False (keep all-distinct rows): a duplicate seen in a subset - # stays a duplicate, so rows can be dropped early. - # exclude=True (keep rows with a duplicate): a row that looks distinct so - # far may still gain a duplicate from a later column, so all parameters - # must be present first. + n_available = len(available & set(self.parameters)) if self.exclude: - return self._required_parameters <= available - return len(available & set(self.parameters)) >= 2 + # Once even assigning every missing parameter the same value cannot + # exceed the maximum, the row is guaranteed to be excluded. + return n_available >= len(self.parameters) - self.n_max_repetitions + 1 + # Exceeding the maximum requires at least one more available parameter. + return n_available >= self.n_max_repetitions + 1 @override def _get_matching_rows(self, df: pd.DataFrame, /) -> pd.Index: params = [p for p in self.parameters if p in df] - mask_good = df[params].nunique(axis=1) == len(params) + + # Encode all values to integer codes with a single global mapping so that + # equality matches pandas semantics exactly (avoids false duplicates that a + # naive string cast would introduce, e.g. int 1 vs. str "1"). Sorting the + # integer codes per row groups equal values together. + block = df[params].to_numpy() + codes = pd.factorize(block.ravel())[0].reshape(block.shape) + sorted_codes = np.sort(codes, axis=1) + + # Mark the start of each run of equal values along the sorted row, then + # assign an increasing run id to every position via a cumulative sum. + is_run_start = np.empty(sorted_codes.shape, dtype=bool) + is_run_start[:, 0] = True + is_run_start[:, 1:] = sorted_codes[:, 1:] != sorted_codes[:, :-1] + run_ids = np.cumsum(is_run_start, axis=1) + + # The largest run (i.e. the highest per-value multiplicity) is found by + # counting, for each possible run id, how many positions carry it. This + # loop runs over the (small) number of parameters, not the dataframe rows. + max_multiplicity = np.zeros(sorted_codes.shape[0], dtype=int) + for run_id in range(1, sorted_codes.shape[1] + 1): + max_multiplicity = np.maximum( + max_multiplicity, (run_ids == run_id).sum(axis=1) + ) + + n_missing = len(self.parameters) - len(params) + max_possible_multiplicity = ( + max_multiplicity + n_missing if self.exclude else max_multiplicity + ) + mask_good = max_possible_multiplicity <= self.n_max_repetitions return df.index[mask_good] @override - def _get_matching_rows_polars(self) -> pl.Expr: + def _get_matching_rows_polars(self, schema: pl.Schema) -> pl.Expr: from baybe._optional.polars import polars as pl - expr = pl.concat_list(pl.col(self.parameters)).list.n_unique() == len( - self.parameters - ) + def _safe_eq(ci: str, cj: str) -> pl.Expr: + """Compare two columns, returning ``False`` for incompatible dtypes.""" + di, dj = schema[ci], schema[cj] + if di == dj or (di.is_numeric() and dj.is_numeric()): + return pl.col(ci).eq_missing(pl.col(cj)) + return pl.lit(False) - return expr + params = self.parameters + counts = [pl.sum_horizontal(_safe_eq(ci, cj) for cj in params) for ci in params] + max_count = pl.max_horizontal(counts) + return max_count <= self.n_max_repetitions -@define -class DiscreteLinkedParametersConstraint(DiscreteFilteringConstraint): - """Constraint class for linking the values of parameters. - This constraint type effectively allows generating parameter sets that relate to - the same underlying quantity, e.g. two parameters that represent the same molecule - using different encodings. Linking the parameters keeps only entries where all - parameter values are identical. - """ +# >>>>>>>>>> Deprecation +def DiscreteNoLabelDuplicatesConstraint( # noqa: N802 + parameters: list[str], +) -> DiscreteRepetitionConstraint: + """A :class:`DiscreteRepetitionConstraint` alias for backward compatibility.""" # noqa: D401 + import warnings - @override - def _can_evaluate(self, available: set[str], /) -> bool: - # exclude=False (keep all-identical rows): values that already differ in a - # subset stay different, so rows can be dropped early. - # exclude=True (keep non-identical rows): a row that looks identical so far - # may still differ once a later column is added, so all parameters must be - # present first. - if self.exclude: - return self._required_parameters <= available - return len(available & set(self.parameters)) >= 2 + flds = fields(DiscreteRepetitionConstraint) + warnings.warn( + f"'{DiscreteNoLabelDuplicatesConstraint.__name__}' is deprecated and will be " + f"removed in a future version. Use '{DiscreteRepetitionConstraint.__name__}' " + f"with '{flds.n_max_repetitions.alias}=1' instead.", + DeprecationWarning, + stacklevel=2, + ) + return DiscreteRepetitionConstraint(parameters=parameters, n_max_repetitions=1) - @override - def _get_matching_rows(self, df: pd.DataFrame, /) -> pd.Index: - params = [p for p in self.parameters if p in set(df.columns)] - mask_good = df[params].nunique(axis=1) == 1 - return df.index[mask_good] +def DiscreteLinkedParametersConstraint( # noqa: N802 + parameters: list[str], +) -> DiscreteRepetitionConstraint: + """A :class:`DiscreteRepetitionConstraint` alias for backward compatibility.""" # noqa: D401 + import warnings - @override - def _get_matching_rows_polars(self) -> pl.Expr: - from baybe._optional.polars import polars as pl + flds = fields(DiscreteRepetitionConstraint) + warnings.warn( + f"'{DiscreteLinkedParametersConstraint.__name__}' is deprecated and will be " + f"removed in a future version. Use '{DiscreteRepetitionConstraint.__name__}' " + f"with '{flds.n_max_repetitions.alias}=len(parameters)-1' and " + f"'{flds.exclude.alias}=True' instead.", + DeprecationWarning, + stacklevel=2, + ) + return DiscreteRepetitionConstraint( + parameters=parameters, + n_max_repetitions=len(parameters) - 1, + exclude=True, + ) - expr = pl.concat_list(pl.col(self.parameters)).list.n_unique() == 1 - return expr +# <<<<<<<<<< Deprecation @define @@ -321,6 +464,24 @@ class DiscreteDependenciesConstraint(DiscreteFilteringConstraint): For instance some parameters might only be relevant when another parameter has a certain value (e.g. parameter switch is 'on'). All dependencies must be declared in a single constraint. + + Examples: + >>> df = pd.DataFrame({ + ... "Switch": ["on", "off", "off"], + ... "Temp": [100, 200, 100], + ... }) + >>> df + Switch Temp + 0 on 100 + 1 off 200 + 2 off 100 + >>> c = DiscreteDependenciesConstraint( + ... parameters=["Switch"], + ... conditions=[SubSelectionCondition(selection=["on"])], + ... affected_parameters=[["Temp"]], + ... ) + >>> list(c.get_invalid(df)) + [2] """ # object variables @@ -434,6 +595,17 @@ class DiscretePermutationInvarianceConstraint(DiscreteFilteringConstraint): *Note:* This constraint is evaluated during creation. In the future it might also be evaluated during modeling to make use of the invariance. + + Examples: + >>> df = pd.DataFrame({"A": ["x", "y", "z"], "B": ["y", "x", "x"]}) + >>> df + A B + 0 x y + 1 y x + 2 z x + >>> c = DiscretePermutationInvarianceConstraint(parameters=["A", "B"]) + >>> list(c.get_invalid(df)) + [1] """ # object variables @@ -590,7 +762,21 @@ def subset_masks( @define class DiscreteCardinalityConstraint(CardinalityConstraint, DiscreteFilteringConstraint): - """Class for discrete cardinality constraints.""" + """Class for discrete cardinality constraints. + + Examples: + >>> df = pd.DataFrame({"A": [0.0, 1.0, 1.0], "B": [0.0, 0.0, 1.0]}) + >>> df + A B + 0 0.0 0.0 + 1 1.0 0.0 + 2 1.0 1.0 + >>> c = DiscreteCardinalityConstraint( + ... parameters=["A", "B"], max_cardinality=1 + ... ) + >>> list(c.get_invalid(df)) + [2] + """ # Class variables numerical_only: ClassVar[bool] = True @@ -627,8 +813,7 @@ def _get_matching_rows(self, df: pd.DataFrame, /) -> pd.Index: # effort to minimize total time in their sequential application DISCRETE_CONSTRAINTS_FILTERING_ORDER = ( DiscreteSelectionConstraint, - DiscreteNoLabelDuplicatesConstraint, - DiscreteLinkedParametersConstraint, + DiscreteRepetitionConstraint, DiscreteSumConstraint, DiscreteProductConstraint, DiscreteCardinalityConstraint, @@ -645,10 +830,18 @@ def _get_matching_rows(self, df: pd.DataFrame, /) -> pd.Index: # >>>>>>>>>> Deprecation def _structure_constraint_compat(val: dict, cls: type) -> Constraint: """Structure hook that redirects legacy constraint type names.""" + val = dict(val) # copy before mutating if val.get(_TYPE_FIELD) == "DiscreteExcludeConstraint": - val = dict(val) # copy before mutating val[_TYPE_FIELD] = "DiscreteSelectionConstraint" - val.setdefault("exclude", True) + val["exclude"] = True + elif val.get(_TYPE_FIELD) == "DiscreteNoLabelDuplicatesConstraint": + val[_TYPE_FIELD] = "DiscreteRepetitionConstraint" + val["n_max_repetitions"] = 1 + elif val.get(_TYPE_FIELD) == "DiscreteLinkedParametersConstraint": + val[_TYPE_FIELD] = "DiscreteRepetitionConstraint" + if (params := val.get("parameters")) is not None and len(params) >= 2: + val["n_max_repetitions"] = len(params) - 1 + val["exclude"] = True return make_base_structure_hook(cls)(val, cls) diff --git a/baybe/searchspace/utils.py b/baybe/searchspace/utils.py index 786c37805d..8a84efd2da 100644 --- a/baybe/searchspace/utils.py +++ b/baybe/searchspace/utils.py @@ -288,8 +288,9 @@ def _apply_constraint_filter_polars( Returns: The Polars lazyframe with undesired rows removed. """ + schema = ldf.collect_schema() for c in constraints: - to_keep = c.get_invalid_polars().not_() + to_keep = c.get_invalid_polars(schema).not_() ldf = ldf.filter(to_keep) return ldf diff --git a/docs/components/constraints.md b/docs/components/constraints.md index 0f5778823c..0e9da29dcd 100644 --- a/docs/components/constraints.md +++ b/docs/components/constraints.md @@ -296,44 +296,38 @@ DiscreteSumConstraint( An end to end example can be found [here](../../examples/Constraints_Discrete/prodsum_constraints). -#### DiscreteNoLabelDuplicatesConstraint -Sometimes, duplicated labels in several parameters are undesirable. -Consider an example with two solvents that describe different mixture -components. -These might have the exact same or overlapping sets of possible values, e.g. -`["Water", "THF", "Octanol"]`. -It would not necessarily be reasonable to allow values in which both solvents show the -same label/component. -The [`DiscreteNoLabelDuplicatesConstraint`](baybe.constraints.discrete.DiscreteNoLabelDuplicatesConstraint) -keeps only those entries whose labels are all distinct: +#### DiscreteRepetitionConstraint +The [`DiscreteRepetitionConstraint`](baybe.constraints.discrete.DiscreteRepetitionConstraint) +controls value repetition across a group of parameters. It keeps only rows where the +largest number of times any single value appears does not exceed +``n_max_repetitions`` (default: ``1``). + +The following example ensures that no solvent label is used more than once across three +mixture slots: ```python -from baybe.constraints import DiscreteNoLabelDuplicatesConstraint +from baybe.constraints import DiscreteRepetitionConstraint -DiscreteNoLabelDuplicatesConstraint(parameters=["Solvent_1", "Solvent_2"]) +DiscreteRepetitionConstraint( + parameters=["Solvent_1", "Solvent_2", "Solvent_3"], + n_max_repetitions=1, +) ``` -With this constraint, combinations with duplicated labels are removed: - -| | Solvent_1 | Solvent_2 | With DiscreteNoLabelDuplicatesConstraint | -|---|-----------|-----------|------------------------------------------| -| 1 | Water | Water | removed | -| 2 | THF | Water | kept | -| 3 | Octanol | Octanol | removed | +| | Solvent_1 | Solvent_2 | Solvent_3 | With DiscreteRepetitionConstraint | +|---|-----------|-----------|-----------|-----------------------------------| +| 1 | Water | Water | THF | removed (Water appears twice) | +| 2 | THF | Water | Octanol | kept | +| 3 | Octanol | Octanol | Octanol | removed (Octanol appears 3 times) | -The usage of `DiscreteNoLabelDuplicatesConstraint` is part of the -[example on slot-based mixtures](../../examples/Mixtures/slot_based). +The constraint can also enforce that **all values are identical** by excluding rows +where a value appears at most one fewer times than there are parameters. This is +useful, for instance, when we have one parameter but would like to include it with +several encodings, which then must all refer to the same underlying value: -#### DiscreteLinkedParametersConstraint -The [`DiscreteLinkedParametersConstraint`](baybe.constraints.discrete.DiscreteLinkedParametersConstraint) -is, in a sense, the opposite of the -[`DiscreteNoLabelDuplicatesConstraint`](baybe.constraints.discrete.DiscreteNoLabelDuplicatesConstraint). -It keeps **only** entries where the linked parameters share the same label. -This can be useful, for instance, in situations where we have one parameter but would -like to include it with several encodings: ```python from baybe.parameters import SubstanceParameter -from baybe.constraints import DiscreteLinkedParametersConstraint +from baybe.constraints import DiscreteRepetitionConstraint dict_solvents = {"Water": "O", "THF": "C1CCOC1", "Octanol": "CCCCCCCCO"} solvent_encoding1 = SubstanceParameter( @@ -346,16 +340,21 @@ solvent_encoding2 = SubstanceParameter( data=dict_solvents, encoding="MORDRED", ) -DiscreteLinkedParametersConstraint( - parameters=["Solvent_RDKIT_enc", "Solvent_MORDRED_enc"] +DiscreteRepetitionConstraint( + parameters=["Solvent_RDKIT_enc", "Solvent_MORDRED_enc"], + n_max_repetitions=1, # = number of parameters - 1 + exclude=True, ) ``` -| | Solvent_RDKIT_enc | Solvent_MORDRED_enc | With DiscreteLinkedParametersConstraint | -|---|-------------------|---------------------|-----------------------------------------| -| 1 | Water | Water | kept | -| 2 | THF | Water | removed | -| 3 | Octanol | Octanol | kept | +| | Solvent_RDKIT_enc | Solvent_MORDRED_enc | With DiscreteRepetitionConstraint | +|---|-------------------|---------------------|-----------------------------------| +| 1 | Water | Water | kept | +| 2 | THF | Water | removed | +| 3 | Octanol | Octanol | kept | + +The usage of `DiscreteRepetitionConstraint` is part of the +[example on slot-based mixtures](../../examples/Mixtures/slot_based). #### DiscreteDependenciesConstraint A dependency is a situation where parameters depend on other parameters. diff --git a/docs/conf.py b/docs/conf.py index f5c3da0bba..401ef78ad3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,6 +4,7 @@ # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html +import polars as pl from gpytorch.kernels import Kernel as GPyTorchKernel from gpytorch.likelihoods import Likelihood as GPyTorchLikelihood from gpytorch.means import Mean as GPyTorchMean @@ -17,6 +18,8 @@ # though not pretty, it is the best solution to this problem. # If future implementations of the corresponding factories rely on different imports # not listed here, this will be flagged by the docs pipeline. +from baybe.constraints import base as _constraint_base +from baybe.constraints import discrete as _constraint_discrete from baybe.surrogates.gaussian_process.components import kernel as _kernel from baybe.surrogates.gaussian_process.components import likelihood as _likelihood from baybe.surrogates.gaussian_process.components import mean as _mean @@ -24,6 +27,8 @@ from baybe.surrogates.gaussian_process.presets import edbo as _edbo from baybe.surrogates.gaussian_process.presets import edbo_smoothed as _edbo_smoothed +_constraint_base.pl = pl +_constraint_discrete.pl = pl _kernel.GPyTorchKernel = GPyTorchKernel _likelihood.GPyTorchLikelihood = GPyTorchLikelihood _mean.GPyTorchMean = GPyTorchMean diff --git a/examples/Mixtures/slot_based.py b/examples/Mixtures/slot_based.py index ee1cef9a7c..5386e39ccd 100644 --- a/examples/Mixtures/slot_based.py +++ b/examples/Mixtures/slot_based.py @@ -48,8 +48,8 @@ from baybe.constraints import ( DiscreteDependenciesConstraint, - DiscreteNoLabelDuplicatesConstraint, DiscretePermutationInvarianceConstraint, + DiscreteRepetitionConstraint, DiscreteSumConstraint, ThresholdCondition, ) @@ -126,10 +126,11 @@ # having two slots with the same substance or having only one slot with the combined # amounts. Thus, we want to make sure that there are no such duplicate label entries, # which can be achieved using a -# {class}`~baybe.constraints.discrete.DiscreteNoLabelDuplicatesConstraint`: +# {class}`~baybe.constraints.discrete.DiscreteRepetitionConstraint`: -no_duplicates_constraint = DiscreteNoLabelDuplicatesConstraint( - parameters=["Slot1_Label", "Slot2_Label", "Slot3_Label"] +no_duplicates_constraint = DiscreteRepetitionConstraint( + parameters=["Slot1_Label", "Slot2_Label", "Slot3_Label"], + n_max_repetitions=1, ) #### Permutation Invariance diff --git a/tests/conftest.py b/tests/conftest.py index e6ab22883d..7a28450a76 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,10 +33,9 @@ DiscreteCardinalityConstraint, DiscreteCustomConstraint, DiscreteDependenciesConstraint, - DiscreteLinkedParametersConstraint, - DiscreteNoLabelDuplicatesConstraint, DiscretePermutationInvarianceConstraint, DiscreteProductConstraint, + DiscreteRepetitionConstraint, DiscreteSelectionConstraint, DiscreteSumConstraint, SubSelectionCondition, @@ -506,8 +505,9 @@ def custom_function(df: pd.DataFrame) -> pd.Series: ], exclude=True, ), - "Constraint_7": DiscreteNoLabelDuplicatesConstraint( + "Constraint_7": DiscreteRepetitionConstraint( parameters=["Solvent_1", "Solvent_2", "Solvent_3"], + n_max_repetitions=1, ), "Constraint_8": DiscreteSumConstraint( parameters=["Fraction_1", "Fraction_2"], @@ -548,8 +548,10 @@ def custom_function(df: pd.DataFrame) -> pd.Series: min_cardinality=1, max_cardinality=2, ), - "Constraint_15": DiscreteLinkedParametersConstraint( + "Constraint_15": DiscreteRepetitionConstraint( parameters=["Solvent_1", "Solvent_2", "Solvent_3"], + n_max_repetitions=2, + exclude=True, ), "ContiConstraint_1": ContinuousLinearConstraint( parameters=["Conti_finite1", "Conti_finite2"], diff --git a/tests/constraints/test_constrained_cartesian_product.py b/tests/constraints/test_constrained_cartesian_product.py index a3851396e3..5f72927c67 100644 --- a/tests/constraints/test_constrained_cartesian_product.py +++ b/tests/constraints/test_constrained_cartesian_product.py @@ -13,9 +13,8 @@ DISCRETE_CONSTRAINTS_FILTERING_ORDER, DiscreteCardinalityConstraint, DiscreteDependenciesConstraint, - DiscreteLinkedParametersConstraint, - DiscreteNoLabelDuplicatesConstraint, DiscretePermutationInvarianceConstraint, + DiscreteRepetitionConstraint, DiscreteSelectionConstraint, DiscreteSumConstraint, SubSelectionCondition, @@ -47,7 +46,9 @@ def _no_label_duplicates_scenario() -> tuple[ values = ["x", "y", "z", "w"] params = [CategoricalParameter(name=f"P{i}", values=values) for i in range(4)] constraints = [ - DiscreteNoLabelDuplicatesConstraint(parameters=[p.name for p in params]) + DiscreteRepetitionConstraint( + parameters=[p.name for p in params], n_max_repetitions=1 + ) ] return params, constraints @@ -58,7 +59,27 @@ def _linked_parameters_scenario() -> tuple[ values = ["a", "b", "c"] params = [CategoricalParameter(name=f"P{i}", values=values) for i in range(3)] constraints = [ - DiscreteLinkedParametersConstraint(parameters=[p.name for p in params]) + DiscreteRepetitionConstraint( + parameters=[p.name for p in params], + n_max_repetitions=len(params) - 1, + exclude=True, + ) + ] + return params, constraints + + +def _repetition_scenario( + n_max: int, + exclude: bool, +) -> tuple[Sequence[DiscreteParameter], Sequence[DiscreteConstraint]]: + values = ["a", "b", "c", "d"] + params = [CategoricalParameter(name=f"P{i}", values=values) for i in range(4)] + constraints = [ + DiscreteRepetitionConstraint( + parameters=[p.name for p in params], + n_max_repetitions=n_max, + exclude=exclude, + ) ] return params, constraints @@ -179,7 +200,7 @@ def _permutation_invariance_with_dependencies_scenario() -> tuple[ parameters=amount_names, condition=ThresholdCondition(threshold=100, operator="=", tolerance=0.1), ), - DiscreteNoLabelDuplicatesConstraint(parameters=label_names), + DiscreteRepetitionConstraint(parameters=label_names, n_max_repetitions=1), ] return params, constraints @@ -195,7 +216,9 @@ def _mixed_scenario() -> tuple[ NumericalDiscreteParameter(name="Num2", values=[0.0, 50.0, 100.0]), ] constraints = [ - DiscreteNoLabelDuplicatesConstraint(parameters=["Cat1", "Cat2", "Cat3"]), + DiscreteRepetitionConstraint( + parameters=["Cat1", "Cat2", "Cat3"], n_max_repetitions=1 + ), DiscreteSumConstraint( parameters=["Num1", "Num2"], condition=ThresholdCondition(threshold=100, operator="<="), @@ -235,6 +258,18 @@ def _mixed_scenario() -> tuple[ id="permutation_invariance_with_deps", ), pytest.param(_mixed_scenario, id="mixed"), + pytest.param( + partial(_repetition_scenario, 1, True), + id="repetition_max_exclude", + ), + pytest.param( + partial(_repetition_scenario, 2, False), + id="repetition_max_keep", + ), + pytest.param( + partial(_repetition_scenario, 2, True), + id="repetition_max_exclude_general", + ), ], ) def test_constrained_cartesian_product(scenario): diff --git a/tests/constraints/test_constraints_polars.py b/tests/constraints/test_constraints_polars.py index 56a3649abe..65eca94fc5 100644 --- a/tests/constraints/test_constraints_polars.py +++ b/tests/constraints/test_constraints_polars.py @@ -6,7 +6,7 @@ from baybe._optional.info import POLARS_INSTALLED from baybe.constraints import ( - DiscreteLinkedParametersConstraint, + DiscreteCustomConstraint, DiscreteSumConstraint, ThresholdCondition, ) @@ -146,27 +146,15 @@ def test_polars_exclusion(mock_substances, parameters, constraints): @pytest.mark.parametrize("parameter_names", [["Solvent_1", "Solvent_2", "Solvent_3"]]) -@pytest.mark.parametrize("constraint_names", [["Constraint_7"]]) -def test_polars_label_duplicates(parameters, constraints): - """Tests Polars implementation of no-label duplicates constraint.""" - ldf = _lazyframe_from_product(parameters) - ldf = _apply_constraint_filter_polars(ldf, constraints) - - ldf = ldf.with_columns( - pl.concat_list(pl.col(["Solvent_1", "Solvent_2", "Solvent_3"])) - .list.n_unique() - .alias("n_unique") - ) - df = ldf.filter(pl.col("n_unique") != len(parameters)).collect() - - num_entries = len(df) - assert num_entries == 0 - - -@pytest.mark.parametrize("parameter_names", [["Solvent_1", "Solvent_2", "Solvent_3"]]) -@pytest.mark.parametrize("constraint_names", [["Constraint_15"]]) -def test_polars_linked_parameters(parameters, constraints): - """Tests Polars implementation of linked parameters constraint.""" +@pytest.mark.parametrize( + ("constraint_names", "n_unique"), + [ + pytest.param(["Constraint_7"], 3, id="maximum-one"), + pytest.param(["Constraint_15"], 1, id="inverted-maximum"), + ], +) +def test_polars_repetition_constraint(parameters, constraints, n_unique): + """Test the Polars implementation of the repetition constraint.""" ldf = _lazyframe_from_product(parameters) ldf = _apply_constraint_filter_polars(ldf, constraints) @@ -175,7 +163,7 @@ def test_polars_linked_parameters(parameters, constraints): .list.n_unique() .alias("n_unique") ) - df = ldf.filter(pl.col("n_unique") != 1).collect() + df = ldf.filter(pl.col("n_unique") != n_unique).collect() num_entries = len(df) assert num_entries == 0 @@ -248,7 +236,10 @@ def test_mixed_polars_pandas_constraints(): condition=ThresholdCondition(threshold=100, operator="="), ), # Pandas-only: operates on [B, C] — B is shared with the Polars constraint - DiscreteLinkedParametersConstraint(parameters=["B", "C"]), + DiscreteCustomConstraint( + parameters=["B", "C"], + validator=lambda df: df["B"] == df["C"], + ), ] # Naive reference: full product then filter diff --git a/tests/hypothesis_strategies/constraints.py b/tests/hypothesis_strategies/constraints.py index 32e6a7986d..2fff8ed78a 100644 --- a/tests/hypothesis_strategies/constraints.py +++ b/tests/hypothesis_strategies/constraints.py @@ -13,10 +13,9 @@ ) from baybe.constraints.discrete import ( DiscreteDependenciesConstraint, - DiscreteLinkedParametersConstraint, - DiscreteNoLabelDuplicatesConstraint, DiscretePermutationInvarianceConstraint, DiscreteProductConstraint, + DiscreteRepetitionConstraint, DiscreteSelectionConstraint, DiscreteSumConstraint, ) @@ -172,15 +171,10 @@ def discrete_permutation_invariance_constraints( @st.composite def _discrete_constraints( draw: st.DrawFn, - constraint_type: ( - type[DiscreteSumConstraint] - | type[DiscreteProductConstraint] - | type[DiscreteNoLabelDuplicatesConstraint] - | type[DiscreteLinkedParametersConstraint] - ), + constraint_type: type[DiscreteSumConstraint] | type[DiscreteProductConstraint], parameter_names: list[str] | None = None, ): - """Generate discrete constraints.""" + """Generate discrete sum/product constraints.""" if parameter_names is None: params = draw(st.lists(st.text(), unique=True, min_size=1)) else: @@ -198,12 +192,10 @@ def _discrete_constraints( params, condition, coefficients, exclude=exclude ) return DiscreteSumConstraint(params, condition, exclude=exclude) - elif constraint_type is DiscreteProductConstraint: + else: return DiscreteProductConstraint( params, draw(threshold_conditions()), exclude=exclude ) - else: - return constraint_type(params, exclude=exclude) discrete_sum_constraints = partial(_discrete_constraints, DiscreteSumConstraint) @@ -212,15 +204,23 @@ def _discrete_constraints( discrete_product_constraints = partial(_discrete_constraints, DiscreteProductConstraint) """Generate :class:`baybe.constraints.discrete.DiscreteProductConstraint`.""" -discrete_no_label_duplicates_constraints = partial( - _discrete_constraints, DiscreteNoLabelDuplicatesConstraint -) -"""Generate :class:`baybe.constraints.discrete.DiscreteNoLabelDuplicatesConstraint`.""" -discrete_linked_parameters_constraints = partial( - _discrete_constraints, DiscreteLinkedParametersConstraint -) -"""Generate :class:`baybe.constraints.discrete.DiscreteLinkedParametersConstraint`.""" +@st.composite +def discrete_repetition_constraints( + draw: st.DrawFn, parameter_names: list[str] | None = None +): + """Generate :class:`baybe.constraints.discrete.DiscreteRepetitionConstraint`.""" + if parameter_names is None: + params = draw(st.lists(st.text(), unique=True, min_size=2)) + else: + assert len(parameter_names) >= 2 + params = parameter_names + + n_max = draw(st.integers(min_value=1, max_value=len(params) - 1)) + exclude = draw(st.booleans()) + return DiscreteRepetitionConstraint( + params, n_max_repetitions=n_max, exclude=exclude + ) @st.composite @@ -266,8 +266,7 @@ def continuous_linear_constraints( discrete_permutation_invariance_constraints(), discrete_sum_constraints(), discrete_product_constraints(), - discrete_no_label_duplicates_constraints(), - discrete_linked_parameters_constraints(), + discrete_repetition_constraints(), continuous_linear_equality_constraints(), continuous_linear_inequality_constraints(), ] diff --git a/tests/serialization/test_constraint_serialization.py b/tests/serialization/test_constraint_serialization.py index 3debbab4a6..a0c0d8f07a 100644 --- a/tests/serialization/test_constraint_serialization.py +++ b/tests/serialization/test_constraint_serialization.py @@ -8,10 +8,9 @@ from tests.hypothesis_strategies.constraints import ( continuous_linear_constraints, discrete_dependencies_constraints, - discrete_linked_parameters_constraints, - discrete_no_label_duplicates_constraints, discrete_permutation_invariance_constraints, discrete_product_constraints, + discrete_repetition_constraints, discrete_selection_constraints, discrete_sum_constraints, ) @@ -30,12 +29,8 @@ param(discrete_sum_constraints(), id="DiscreteSumConstraint"), param(discrete_product_constraints(), id="DiscreteProductConstraint"), param( - discrete_no_label_duplicates_constraints(), - id="DiscreteNoLabelDuplicatesConstraint", - ), - param( - discrete_linked_parameters_constraints(), - id="DiscreteLinkedParametersConstraint", + discrete_repetition_constraints(), + id="DiscreteRepetitionConstraint", ), param( continuous_linear_constraints(), diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index f3ebfd9c2e..197fdde97f 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -17,8 +17,10 @@ from baybe._optional.info import CHEM_INSTALLED, POLARS_INSTALLED from baybe.constraints import SubSelectionCondition from baybe.constraints import base as base_module +from baybe.constraints import discrete as discrete_module from baybe.constraints.discrete import ( DiscreteExcludeConstraint, + DiscreteRepetitionConstraint, DiscreteSelectionConstraint, ) from baybe.exceptions import DeprecationError @@ -601,3 +603,65 @@ def test_discrete_exclude_constraint_deserialization(annotation): target = getattr(base_module, annotation) result = converter.structure(legacy_dict, target) assert result == ref + + +@pytest.mark.parametrize( + ("legacy_name", "kwargs", "expected"), + [ + pytest.param( + "DiscreteNoLabelDuplicatesConstraint", + {"parameters": ["A", "B", "C"]}, + {"n_max_repetitions": 1}, + id="no_label_duplicates", + ), + pytest.param( + "DiscreteLinkedParametersConstraint", + {"parameters": ["A", "B", "C"]}, + {"n_max_repetitions": 2, "exclude": True}, + id="linked_parameters", + ), + ], +) +def test_repetition_constraint_deprecation(legacy_name, kwargs, expected): + """Constructing deprecated constraints emits a deprecation warning.""" + with pytest.warns(DeprecationWarning, match=legacy_name): + c = getattr(discrete_module, legacy_name)(**kwargs) + ref = DiscreteRepetitionConstraint( + parameters=kwargs["parameters"], + **expected, + ) + assert c == ref + + +@pytest.mark.parametrize( + "annotation", + ["Constraint", "DiscreteConstraint", "DiscreteFilteringConstraint"], +) +@pytest.mark.parametrize( + ("legacy_name", "kwargs", "expected"), + [ + pytest.param( + "DiscreteNoLabelDuplicatesConstraint", + {"parameters": ["A", "B", "C"]}, + {"n_max_repetitions": 1}, + id="no_label_duplicates", + ), + pytest.param( + "DiscreteLinkedParametersConstraint", + {"parameters": ["A", "B", "C"]}, + {"n_max_repetitions": 2, "exclude": True}, + id="linked_parameters", + ), + ], +) +def test_repetition_constraint_deserialization( + annotation, legacy_name, kwargs, expected +): + """Legacy repetition constraints deserialize regardless of the annotation.""" + ref = DiscreteRepetitionConstraint( + parameters=kwargs["parameters"], + **expected, + ) + target = getattr(base_module, annotation) + result = converter.structure({"type": legacy_name, **kwargs}, target) + assert result == ref diff --git a/tests/validation/test_constraint_validation.py b/tests/validation/test_constraint_validation.py index db0aec3876..d36822be31 100644 --- a/tests/validation/test_constraint_validation.py +++ b/tests/validation/test_constraint_validation.py @@ -8,7 +8,10 @@ ContinuousCardinalityConstraint, ContinuousLinearConstraint, ) -from baybe.constraints.discrete import DiscreteSumConstraint +from baybe.constraints.discrete import ( + DiscreteRepetitionConstraint, + DiscreteSumConstraint, +) @pytest.mark.parametrize( @@ -28,6 +31,41 @@ def test_invalid_cardinalities(cardinalities, error, match): ContinuousCardinalityConstraint(["x", "y"], *cardinalities) +@pytest.mark.parametrize( + ("kwargs", "error", "match"), + [ + param( + {"n_max_repetitions": 2.0}, + TypeError, + "must be ", + id="maximum-type", + ), + param( + {"n_max_repetitions": 0}, + ValueError, + "must be >= 1", + id="maximum-too-small", + ), + param( + {"n_max_repetitions": 4}, + ValueError, + "must be less than the number of parameters", + id="maximum-too-large", + ), + param( + {"n_max_repetitions": 3}, + ValueError, + "meaningful constraint", + id="maximum-only-no-op", + ), + ], +) +def test_invalid_max_repetitions(kwargs, error, match): + """Invalid maximum repetition counts raise an exception.""" + with pytest.raises(error, match=match): + DiscreteRepetitionConstraint(parameters=["A", "B", "C"], **kwargs) + + @pytest.mark.parametrize( ("coefficients", "match"), [