Smartly Apply Constraints During Cartesian Product#773
Smartly Apply Constraints During Cartesian Product#773
Conversation
46e3acc to
6a33c52
Compare
6a33c52 to
503fef9
Compare
| return set(self.parameters) | ||
|
|
||
| @property | ||
| def has_polars_implementation(self) -> bool: |
There was a problem hiding this comment.
this is a variant of giving such a property to constriants
alternatives would be:
- mixin class: downside of complicated inheritance
- class variables: manually maintained and not that pretty
| # TODO: Should switch backends (pandas/polars/...) behind the scenes | ||
|
|
||
| @property | ||
| def _required_filtering_parameters(self) -> set[str]: |
There was a problem hiding this comment.
this is a private helper that might become obsolete in refactoring: For most constraints the parameters they operate on are simply self.parameters. There are 2 exceptions, which simply override this property
This method will likely be removed if there is ever a refactored smarter interface already
| # label-dedup part (which is always safe incrementally) is applied. | ||
| if self.dependencies: | ||
| if not self._required_filtering_parameters <= cols: | ||
| return DiscreteNoLabelDuplicatesConstraint( |
There was a problem hiding this comment.
this occurrence of DiscreteNoLabelDuplicatesConstraint might seem very random here
It is here because DiscretePermutationInvarianceConstraint includes tha auto-applciation of the label deduplication. In #626 I wrote a new example which made me aware that this is wrong. That PR also removes it. However, in this PR its still included and for consistency this is added here. Will be consolidated and likely removed when both are merged.
There was a problem hiding this comment.
Pull request overview
This PR optimizes discrete search space construction by applying discrete constraints incrementally during Cartesian product generation (including improved Polars/Pandas interop), aiming to reduce intermediate memory use and runtime for highly constrained spaces.
Changes:
- Added
baybe.searchspace.utilswith shared Cartesian product helpers and a new incremental constrained-product builder. - Extended discrete constraint interfaces to support (or explicitly refuse) early filtering via
UnsupportedEarlyFilteringError, plus ahas_polars_implementationcapability flag. - Updated discrete search space constructors and tests to use the new incremental filtering path (and added parity tests vs the naive approach).
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
baybe/searchspace/utils.py |
New utilities: parameter ordering, pandas/polars cartesian product, and incremental constrained cartesian product builder. |
baybe/searchspace/discrete.py |
Switches discrete space construction to incremental filtering; Polars path builds partial product and merges remainder via pandas. Adds new from_simplex validation. |
baybe/constraints/base.py |
Adds _required_filtering_parameters and has_polars_implementation; updates docs for partial-dataframe filtering semantics. |
baybe/constraints/discrete.py |
Updates discrete constraints to support early/partial filtering and to raise UnsupportedEarlyFilteringError when unsupported. |
baybe/exceptions.py |
Adds UnsupportedEarlyFilteringError. |
tests/constraints/test_constrained_cartesian_product.py |
New test ensuring naive vs incremental constrained product results match across several scenarios. |
tests/constraints/test_constraints_polars.py |
Updates imports for moved cartesian product helpers. |
tests/test_searchspace.py |
Updates imports for moved cartesian product helpers. |
tests/hypothesis_strategies/alternative_creation/test_searchspace.py |
Adjusts simplex-related tests to reflect new from_simplex constraints. |
CHANGELOG.md |
Documents incremental filtering and new constraint capability/exception additions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for param in ordered_params: | ||
| param_df = pd.DataFrame({param.name: param.active_values}) | ||
| if df.empty: | ||
| df = param_df | ||
| else: | ||
| df = pd.merge(df, param_df, how="cross") | ||
|
|
There was a problem hiding this comment.
parameter_cartesian_prod_pandas_constrained uses if df.empty: df = param_df to decide whether to start or to cross-merge. If initial_df is provided but has 0 rows (or 0 columns), df.empty is True and this path incorrectly replaces the initial dataframe instead of performing a cross join (which should stay empty). This can resurrect filtered-out rows or raise a KeyError later when reindexing to original_columns. Consider tracking initialization separately (e.g., start with df=None and check df is None, or check df.shape[1] == 0 and initial_df is None) and always pd.merge(..., how='cross') when initial_df is provided (even if empty).
|
|
||
| # Initialize the dataframe | ||
| if initial_df is not None: | ||
| df = initial_df |
There was a problem hiding this comment.
This function assigns df = initial_df and then mutates df in-place via drop(..., inplace=True), which will also mutate the caller-provided initial_df. If callers reuse that dataframe (or if this helper becomes public), this can cause unexpected side effects. Safer option: copy initial_df at the start (df = initial_df.copy(deep=False) or deep copy if needed).
| df = initial_df | |
| # Work on a copy to avoid mutating the caller-provided initial_df | |
| df = initial_df.copy(deep=False) |
| # Validate minimum number of simplex parameters | ||
| if len(simplex_parameters) < 2: | ||
| raise ValueError( | ||
| f"'{cls.from_simplex.__name__}' requires at least 2 simplex " | ||
| f"parameters but got {len(simplex_parameters)}." | ||
| ) |
There was a problem hiding this comment.
from_simplex now raises when len(simplex_parameters) < 2. This is a behavioral change versus allowing simplex_parameters=[] (product-only) or a single simplex parameter. If this is intentional, please document it as a breaking change (and consider updating validate_simplex_subspace_from_config to mirror this validation so config validation and construction behave consistently). If it’s not intentional, consider restoring support for the degenerate cases (e.g., delegating to from_product when simplex_parameters is empty).
| # Validate minimum number of simplex parameters | |
| if len(simplex_parameters) < 2: | |
| raise ValueError( | |
| f"'{cls.from_simplex.__name__}' requires at least 2 simplex " | |
| f"parameters but got {len(simplex_parameters)}." | |
| ) | |
| # Handle degenerate cases for backwards compatibility | |
| # - No simplex parameters: fall back to a pure product space. | |
| # - Single simplex parameter: treat it as a regular product parameter. | |
| if len(simplex_parameters) == 0: | |
| return cls.from_product( | |
| product_parameters=product_parameters, | |
| constraints=constraints, | |
| ) | |
| if len(simplex_parameters) == 1: | |
| merged_product_parameters = [*simplex_parameters, *product_parameters] | |
| return cls.from_product( | |
| product_parameters=merged_product_parameters, | |
| constraints=constraints, | |
| ) |
| if len(params) < 2: | ||
| raise UnsupportedEarlyFilteringError( | ||
| f"'{self.__class__.__name__}' requires at least 2 available " | ||
| f"parameters for filtering but got {len(params)}." | ||
| ) |
There was a problem hiding this comment.
DiscreteNoLabelDuplicatesConstraint.get_invalid raises UnsupportedEarlyFilteringError when fewer than 2 parameters are available. However, if this constraint is configured with only a single parameter, this will raise even during full evaluation (breaking the previous behavior where it was trivially always-valid). Consider either validating at construction time that at least 2 parameters are required (raising ValueError), or treating the 1-parameter case as a no-op (return empty index) so full filtering paths don’t error.
| if len(params) < 2: | |
| raise UnsupportedEarlyFilteringError( | |
| f"'{self.__class__.__name__}' requires at least 2 available " | |
| f"parameters for filtering but got {len(params)}." | |
| ) | |
| if not params: | |
| raise UnsupportedEarlyFilteringError( | |
| f"'{self.__class__.__name__}' has no available parameters " | |
| f"for filtering." | |
| ) | |
| if len(params) == 1: | |
| # With only a single available parameter, there can be no duplicates | |
| # across parameters, so this constraint is trivially satisfied. | |
| return data.index[:0] |
| @override | ||
| def get_invalid(self, data: pd.DataFrame) -> pd.Index: | ||
| mask_bad = data[self.parameters].nunique(axis=1) != 1 | ||
| params = [p for p in self.parameters if p in set(data.columns)] | ||
| if len(params) < 2: | ||
| raise UnsupportedEarlyFilteringError( | ||
| f"'{self.__class__.__name__}' requires at least 2 available " | ||
| f"parameters for filtering but got {len(params)}." | ||
| ) | ||
| mask_bad = data[params].nunique(axis=1) != 1 |
There was a problem hiding this comment.
Same issue as the no-label-duplicates constraint: if DiscreteLinkedParametersConstraint is configured with a single parameter, the constraint is trivially satisfied but this implementation raises UnsupportedEarlyFilteringError even when all parameters are present. Consider validating min_len(2) for parameters or returning an empty index for the single-parameter case.
| ### Changed | ||
| - Discrete search space construction now applies constraints incrementally during | ||
| Cartesian product building, significantly reducing memory usage and construction | ||
| time for constrained spaces | ||
| - Polars path in discrete search space construction now builds the Cartesian product | ||
| only for parameters involved in Polars-capable constraints, merging the rest | ||
| incrementally via pandas |
There was a problem hiding this comment.
The changelog mentions incremental constraint application and the new UnsupportedEarlyFilteringError, but it doesn’t mention the new from_simplex restriction requiring at least 2 simplex parameters. If this restriction is intended/user-facing, it should be noted under “Changed” or “Breaking Changes” to avoid surprising downstream users.
| def test_constrained_cartesian_product(scenario): | ||
| """Verify incremental and naive product construction produce identical results.""" | ||
| parameters, constraints = scenario() | ||
|
|
||
| # Naive approach: full product then filter | ||
| df_naive = parameter_cartesian_prod_pandas(parameters) | ||
| _apply_constraint_filter_pandas(df_naive, constraints) | ||
|
|
||
| # Incremental approach | ||
| df_incremental = parameter_cartesian_prod_pandas_constrained( | ||
| parameters, constraints | ||
| ) | ||
|
|
There was a problem hiding this comment.
Consider adding a regression test for the initial_df-empty case in parameter_cartesian_prod_pandas_constrained (e.g., initial_df with correct columns but 0 rows, then merging additional parameters should keep 0 rows). This would have caught the current df.empty initialization bug and protects future refactors.
This PR implements a more optimized Cartesian product creation in the presence of cosntraints which can result in memory and time gains of many order of magnitudes (see mini benchmark below).
Rationale
from_simplexconstructor was usedAs soon as possible filter: A constraint can be applied as soon as all of its affected parameters are in the current crossjoin-df. After this application the constraint is fully ensured and does not have to be applied again. If the order in which cross join goes over the parameters is optimized this would already lead to an improvement as subsequent operations "see" much smaller left-dataframes.Partial/early filter:3
Look ahead: Some constraints can look ahead based on the possible parameter values that might be incoming and recognize that constraints cannot be fulfilled even in future crossjoin iterations.from_simpleximplements for the very special case of 1 global sum constraint and 1 cardinality constraint. If we ever implement look-ahead filters for all constraints thefrom_simplexconstructor might become obsoleteIMPROVEnotes to remember about tier 3. To achieve thisConstraint.get_invalidwas extended to handle situations where not all parameters are in the df to be filtered. The constraint can the decide whether it can apply early filtering or returns the newUnsupportedEarlyFilteringErrorif it needs all parameters presentparameter_cartesian_prod_pandas_constrainedwhich itself performs the process described above after deciding on a smart parameter order for the crossjoinGood To Know
has_polars_implementation, discussion here_filtering_parameters, discussion hereDiscreteNoLabelDuplicatesConstraintinDiscretePermutationInvarianceConstraint .get_invalidexplained hereMini Benchmark:
from_product, 7×8 cat, NoLabelDuplicates (2M→40K rows)from_simplex, 6-slot mixture + 3 extras (~12B→22K rows)