Conversation
4a381ec to
1c3bbef
Compare
There was a problem hiding this comment.
Pull request overview
Adds a new batch-level discrete constraint (DiscreteBatchConstraint) that enforces a fixed discrete parameter value across all points in a recommended batch by optimizing over discrete (and hybrid) subspaces, aligning with the existing subspace-based infrastructure used for continuous cardinality constraints.
Changes:
- Introduces
DiscreteBatchConstraint(non-filtering, batch-level) plus validation preventing duplicate batch constraints per parameter. - Extends discrete/hybrid subspace infrastructure (
n_theoretical_subspaces, mask/config iterators, sampling helpers) and wires support intoBotorchRecommenderandRandomRecommenderwith compatibility gating for other recommenders. - Adds documentation and test coverage for discrete + hybrid subspace-generating constraints, replacing prior hybrid-only cardinality tests.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
baybe/constraints/discrete.py |
Adds DiscreteBatchConstraint and its subspace mask generation. |
baybe/constraints/validation.py |
Adds validation to prevent multiple batch constraints on the same parameter. |
baybe/constraints/__init__.py |
Exposes DiscreteBatchConstraint publicly. |
baybe/searchspace/discrete.py |
Adds discrete subspace-generating constraint plumbing and mask enumeration/sampling helpers. |
baybe/searchspace/continuous.py |
Renames/aligns to n_theoretical_subspaces and adds shuffled / with-replacement subspace configuration iteration. |
baybe/searchspace/core.py |
Adds combined discrete+continuous theoretical subspace counting and combined sampling utilities for hybrid spaces. |
baybe/recommenders/pure/base.py |
Adds supports_discrete_subspace_constraints gate and raises IncompatibilityError when unsupported. |
baybe/recommenders/pure/bayesian/botorch.py |
Implements discrete + hybrid optimization over subspaces when batch constraints are present. |
baybe/recommenders/pure/nonpredictive/sampling.py |
Updates RandomRecommender to respect discrete subspace-generating constraints. |
docs/userguide/constraints.md |
Documents DiscreteBatchConstraint and recommender compatibility. |
tests/constraints/test_batch_constraint.py |
Adds focused tests for the new constraint (behavior, validation, incompatibility, subspace counting/masks). |
tests/constraints/test_subspace_constraints_hybrid.py |
Adds parametrized hybrid tests covering discrete batch + (discrete/continuous) cardinality constraints. |
tests/constraints/test_cardinality_constraint_hybrid.py |
Removes older hybrid cardinality-only test file (superseded). |
CHANGELOG.md |
Records new user-facing feature entry. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
This comment was marked as resolved.
This comment was marked as resolved.
dd5bbf6 to
23b3348
Compare
AVHopp
left a comment
There was a problem hiding this comment.
I do not have any major comments. I personally find it a bit weird that the concept of a "partition" is somehow identical in both discrete and continuous subspaces, but caused by completely different things. However, I do not see any more elegant solution to this, and this might also be personal preference.
843793f to
a188d66
Compare
|
@AdrianSosic appreciate your review |
AdrianSosic
left a comment
There was a problem hiding this comment.
Hey @Scienfitz, thanks for the new feature, very good to have indeed. I'm already submitting the review so that you have something to work with, but I still need to read some parts of the partitioning logic
| def __attrs_post_init__(self): | ||
| """Validate that exactly one parameter is specified.""" | ||
| if len(self.parameters) != 1: | ||
| raise ValueError( | ||
| f"'{self.__class__.__name__}' requires exactly one parameter, " | ||
| f"but {len(self.parameters)} were provided: {self.parameters}." |
There was a problem hiding this comment.
Can we get rid of this restriction? My first naive guess would be that it should not be too hard?
There was a problem hiding this comment.
this is unnecessary
it is also already kind of already implemented in the way that you can just use several of such constraints, imo that is much clearer than allowing several parameters for this constraint
allowing >1 parameters here would open the possibility of confusion
There was a problem hiding this comment.
Really? To me, it's rather the current design that is super unintuitive. We have an object that takes parameters (plural!) and then we are only allowed to pass a single one, which needs to be validated using additional logic. And then we allow the option to have several such constraints, which effectively does what would happen if we simply allowed more parameters in the first place.
Where do you see potential confusion? I can only see one way how to interpret a call like parameters=["A", "B"], namely exactly the way you've implemented with several such constraints. The mean can only go columnwise – there is no way that A and B would possibly interfere since they have their own separate value ranges.
There was a problem hiding this comment.
so there is technical debt here causing this confusion, it is just parameters because all constraints currently share this. This is to be altered/fixed in #517 and not in this PR
But it has now driven me into this weird design that the constraint has parameters but only accepts 1 of them
I would much prefer A:
- that you have exactly one batch constraint per parameter you want to constrain
- as many batch constraints (for different parameters) as you want
With the alternative being B:
- only one allowed batch constraint in the searchspace
- it takes multiple unique parameters
I prefer A because the filtering logic is entirely orthogonal, so expressing it as independent constraints make sense to me. I already made the plan to change this also for the dependencies constraint here #670 so far I have not received any differing opinion
I could live with both variants, but it should be amde consistent with the dependencies as well, so let me know. But please do not make your call based on the parameters name arg
There was a problem hiding this comment.
Or alternative C:
- allow an arbitrary number of parameters per constraint
- allow an arbitrary number of such constraints
I don't know why the above should not be possible, and it would be the most natural option for me since:
- Pretty much all other constraints behave that way (i.e. there is no restriction on the number of parameters for sum constraint, and we can have several of those)
- It feels natural that one might want to use one such constraint per physical limitation in the system. For example, you have one piece of equipment where temperature/pressure must be synced across the batch, but for the other equipment you need to sync the pH value. --> Two constraints, one with temp/pressure, the other with pH
While you could of course achieve the same with three or just one constraint, why take away the possibility to structure it for no reason?
But I think a third opinion is a good idea here: @AVHopp, @kalama-ai?
There was a problem hiding this comment.
I think allowing that (option C) would be worst of both worlds - degeneracy in configurations should be avoided especially if its easy (like in this case)
AND
it would be inconsistent with the cardinality constraints where only one set of parameters is specified instead of potentially multiple sets (the set of parameters over which the constraint holds is equivalent to a single parameter in the batch constraint over which the batch constraint holds)
| @property | ||
| def n_inactive_parameter_combinations(self) -> int: | ||
| """The number of possible inactive parameter combinations.""" | ||
| def n_theoretical_partitions(self) -> int: |
There was a problem hiding this comment.
Let's keep the theoretical debate open until we've agreed on the resampling behavior for the discrete space
| def recommend_discrete_with_partitions( | ||
| recommender: BotorchRecommender, | ||
| subspace_discrete: SubspaceDiscrete, | ||
| candidates_exp: pd.DataFrame, |
There was a problem hiding this comment.
I have a stupid question that might not be related to your PR but is still relevant: why the heck to we actually pass candidates_exp as a separate argument here? If this is really just the experimental representation of the discrete candidates then we could simply get it from subspace_discrete.exp_rep!?
In the old (large) file, there sometimes was docstring like "The experimental representation of all discrete candidate points to be considered.", which sort of indicates to me that this might be a reduced set for whatever reason. Since you just refactored this and perhaps have a better picture in mind: can you explain?
The answer to this also carries over to your new methods, like partition_masks, which also take an explicit candidates_exp which would be available directly from the space!
There was a problem hiding this comment.
the candidates are a subset of the searchspace that is prefiltered so its not the same, or what am I missing?
Creating the masks on the candidate set is not necessarily the same as creating them on the entire discrete searchspace.
| def inactive_parameter_combinations( # noqa: DOC404 | ||
| self, | ||
| *, | ||
| shuffle: bool = False, |
There was a problem hiding this comment.
I have a suggestion for the shuffle argument here and in the corresponding discrete case. What I dislike about the method signature is that we have two seemingly independent arguments which are however coupled in reality (see your docstring for shuffle). This always opens the door for silent "errors", i.e. when someone provides both flags as True. We've had this countless times in other places and I urgently want to avoid these designs in the future (a la avoid invalid/meaningless configs by design).
For this situation here, we might have a very simple answer, which even simplifies the entire thing: as far as I can tell, there is no use case where we'd actually require a deterministic order, right? Also, I don't think there is a canonical default order – after all, this depends on the (arbitrary) order of constraints etc. So why not simply drop the argument altogether and just make shuffle True by default?
There was a problem hiding this comment.
as discussed: awaiting commit with suggestion
|
|
||
| total = math.prod(len(v) for v in per_constraint) | ||
|
|
||
| def _resolve_flat_idx(flat_idx: int) -> frozenset[str]: |
There was a problem hiding this comment.
Can you please add a (simple) docstring just summarizing what the function does? Otherwise, people (like me) have to read the code to understand that we're mapping from enumeration index to its corresponding inactivation sets.
There was a problem hiding this comment.
I think this function exists twice, and unfortunately you looked a exactly the version that has no docstring or comment
the comment for the same function in discrete says:
# Decompose flat index into per-constraint indices.
# Example with 3 constraints of partition lengths [3, 2, 4]:
# flat_idx=11 -> divmod(11,3)=(3,2) -> A[2]
# divmod(3,2)=(1,1) -> B[1]
# divmod(1,4)=(0,1) -> C[1]
# Result: masks A[2] AND B[1] AND C[1]
- Is that enough?
- I guess it could be extracted ina utility by adding another parameter to it, should I do that?
| def __attrs_post_init__(self): | ||
| """Validate that exactly one parameter is specified.""" | ||
| if len(self.parameters) != 1: | ||
| raise ValueError( | ||
| f"'{self.__class__.__name__}' requires exactly one parameter, " | ||
| f"but {len(self.parameters)} were provided: {self.parameters}." |
There was a problem hiding this comment.
Really? To me, it's rather the current design that is super unintuitive. We have an object that takes parameters (plural!) and then we are only allowed to pass a single one, which needs to be validated using additional logic. And then we allow the option to have several such constraints, which effectively does what would happen if we simply allowed more parameters in the first place.
Where do you see potential confusion? I can only see one way how to interpret a call like parameters=["A", "B"], namely exactly the way you've implemented with several such constraints. The mean can only go columnwise – there is no way that A and B would possibly interfere since they have their own separate value ranges.
Co-authored-by: Alexander V. Hopp <alexander.hopp@merckgroup.com>
Consistent with the existing check-return-types=False setting.
Co-authored-by: AdrianSosic <adrian.sosic@merckgroup.com>
Co-authored-by: AdrianSosic <adrian.sosic@merckgroup.com>
Co-authored-by: AdrianSosic <adrian.sosic@merckgroup.com>
Co-authored-by: AdrianSosic <adrian.sosic@merckgroup.com>
Co-authored-by: AdrianSosic <adrian.sosic@merckgroup.com>
Co-authored-by: AdrianSosic <adrian.sosic@merckgroup.com>
- Rename DiscreteBatchConstraint.get_invalid to _get_invalid to align with the abstract method rename introduced on main - Add DiscreteBatchConstraint to DISCRETE_CONSTRAINTS_FILTERING_ORDER so build_constrained_product (introduced on main) can sort it - Ignore BadInitialCandidatesWarning in pytest.ini; the warning fires non-deterministically in heavily constrained spaces regardless of data volume
6224f62 to
615b581
Compare
2ff2f31 to
5b6e220
Compare
5b6e220 to
86aaaf9
Compare
Closes #583
Fixes #567
Functionality:
Adds a
DiscreteBatchConstraintwhich works via subspace generation just like the continuous cardinality constraint, just in the discrete searchspace part. While it will quickly lead to many partitions, it is also possible to use multipleDiscreteBatchConstraints and also combine them with the cardinality constraints because there is now a more unified interface for constraints that work via subspace generation.Notes:
subspaceis not optimal, because there is also the concept ofDiscreteSubspace/ContinuousSubspace. I changed the naming that references to creating subspaces for taking care of the constraint intopartition. So we would then have things likepartition_masks,n_theoretical_partitionsetcn_max_partitionswhich controls the max number of subspaces considered in any casereplace=True) or shuffle the order (shuffle=True). The latter is used when subsamapling the spaces because withshuffle=Trueyou can just instantiate the fistnobjects in the iterator to achieve random subsampling. This has some complications, especially for the overall searchspace class which needs to have a parameter to stop the process when replicated samples are drawn too often (likely no other feasible subspace combinations).botorch.pyin recommenders has become very large as a result of this development. So I split it into submodules_FixedNumericalContinuousParameterhad a bugged property which was namedis_numericbut it should beis_numericalContinuousCardinalityConstraintcan now also be applied in hybrid spaces, which fixesContinuousCardinalityConstraintnot considered in hybrid spaces #567Discuss:
n_theoretical_subspacesjust computes the upper limit of subspaces in discrete and hybrid cases. Because subspaces that do not have enough candidates are internally skipped the actual number ofn_feasible_subspacesmight be smaller. However that cannot be easily computed and would require instantiating all masks corresponding to the subspaces. The dispatcher logic is currently based onn_theoretical_subspacesbut to be 100% correct it should ben_feasible_subspaces- this is not implemented due to the mentioned difficulty. I think usingn_theoretical_subspacesworks as a proxy and will not be very different fromn_feasible_subspacesfor nearly all practical problems