Add outcome constraints#792
Conversation
Introduce the OutcomeConstraint class that references a NumericalTarget directly (rather than by name) and supports comparison operators (<=, >=). The constraint holds a target, an operator, and a threshold value.
Targets used only in outcome constraints do not require an optimization direction. Setting minimize=None signals that the target is not optimized directly but only serves as a constraint reference.
- Add outcome_constraints field to base Objective with validation - Derive constraint_targets property from outcome constraints - Extend targets property to include both optimization and constraint targets - Update SingleTargetObjective, DesirabilityObjective, and ParetoObjective - Guard to_botorch_posterior_transform against constraint targets - Use _optimization_targets consistently for objective-specific logic - Implement to_botorch_constraints() returning list of constraint callables
… builder - Pass outcome constraints from objective to acquisition function construction - Compute best feasible objective value under constraints for analytic acqfs - Handle constraint feasibility evaluation with correct tensor dimensions - Integrate constraints into both analytic and MC acquisition functions
Two-layer guard: Layer 1 warns in PureRecommender.recommend() when a non-Bayesian recommender receives an objective with outcome constraints. Layer 2 raises IncompatibilityError in the acqf builder when the selected acquisition function does not support constraints.
- Integration tests verifying outcome constraints work end-to-end with BotorchRecommender for SingleTarget, Desirability, and Pareto objectives - Validation tests for OutcomeConstraint model constraints
There was a problem hiding this comment.
Pull request overview
Introduces first-class outcome constraints (threshold constraints on target predictions) and wires them through BayBE’s objective/surrogate/acquisition-function stack, with enforcement implemented for BoTorch MC acquisition functions and graceful degradation (warnings) for unsupported recommenders.
Changes:
- Added
OutcomeConstraintAPI and integrated outcome constraints intoObjectivetarget/modeling semantics (opt targets vs constraint targets). - Extended BoTorch acquisition builder to pass
constraintscallables (when supported) and to computebest_fusing feasible training points when constraints exist. - Added validation + behavior tests for constraint creation, objective integration, enforcement, and incompatibility/warning behavior.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/validation/test_outcome_constraint_validation.py | Adds validation tests for invalid constraint/objective combinations and recommender incompatibility behavior. |
| tests/constraints/test_outcome_constraints.py | Adds functional tests for threshold semantics, objective integration, recommendation steering, and constrained best_f. |
| baybe/targets/numerical.py | Allows NumericalTarget.minimize to be None to represent constraint-only targets. |
| baybe/recommenders/pure/base.py | Adds layer-1 warning when outcome constraints are provided to non-Bayesian pure recommenders. |
| baybe/recommenders/naive.py | Adds missing layer-1 warning in hybrid path where discrete recommender bypasses recommend(). |
| baybe/objectives/single.py | Refactors to _optimization_targets and adds string rendering for outcome constraints; guards posterior transform path. |
| baybe/objectives/pareto.py | Refactors to _optimization_targets (optimization targets separated from constraint targets). |
| baybe/objectives/desirability.py | Aligns weight validation and modeled-quantity mapping with _optimization_targets; includes constraint targets when pre-transforming. |
| baybe/objectives/base.py | Core outcome-constraint plumbing: outcome_constraints, constraint_targets, updated targets, and BoTorch constraint callable generation. |
| baybe/exceptions.py | Adds OutcomeConstraintIgnoredWarning. |
| baybe/constraints/outcome.py | Implements OutcomeConstraint and BoTorch constraint callable creation. |
| baybe/constraints/init.py | Exports OutcomeConstraint from constraints package. |
| baybe/acquisition/_builder.py | Adds support for passing constraints to compatible MC acqfs; updates best_f computation to respect feasibility. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| target: Target = field(validator=instance_of(Target)) | ||
| """The target to be constrained.""" | ||
|
|
||
| operator: Literal["<=", ">=", "=="] = field(validator=in_(["<=", ">=", "=="])) | ||
| """The constraint operator.""" | ||
|
|
||
| threshold: float = field(validator=instance_of((int, float)), converter=float) | ||
| """The constraint threshold value in experimental units.""" | ||
|
|
| def __attrs_post_init__(self) -> None: | ||
| """Validate outcome constraints against optimization targets.""" | ||
| # Disallow overlap between optimization and constraint targets | ||
| optimization_names = {t.name for t in self._optimization_targets} | ||
| constraint_names = {t.name for t in self.constraint_targets} | ||
| if overlap := optimization_names & constraint_names: | ||
| raise ValueError( | ||
| f"Targets cannot be both optimized and constrained: {sorted(overlap)}." | ||
| ) | ||
|
|
||
| # Constraint-only targets must have minimize=None | ||
| if invalid := [ | ||
| t.name | ||
| for t in self.constraint_targets | ||
| if isinstance(t, NumericalTarget) and t.minimize is not None | ||
| ]: | ||
| raise ValueError( | ||
| f"Constraint-only targets must have minimize=None, " | ||
| f"but the following do not: {invalid}." | ||
| ) |
| n_points = model_predictions.shape[0] | ||
| feasible_mask = torch.ones(n_points, dtype=torch.bool) | ||
|
|
||
| for constraint_func in constraints: | ||
| # Constraint func: [batch, q, m] -> [batch, q]; we insert q=1 | ||
| # via unsqueeze(-2), so output is [n_points, 1]; squeeze q dim. | ||
| constraint_violations = constraint_func( | ||
| model_predictions.unsqueeze(-2) | ||
| ).squeeze(-1) | ||
| feasible_mask &= constraint_violations <= 0 | ||
|
|
||
| return feasible_mask |
| def to_botorch_constraints( | ||
| self, | ||
| ) -> list[Callable[[torch.Tensor], torch.Tensor]]: | ||
| """Convert outcome constraints to BoTorch constraint callables. | ||
|
|
||
| Returns a list of callables, each mapping samples of shape | ||
| ``sample_shape x batch_shape x q x m`` to constraint values of shape | ||
| ``sample_shape x batch_shape x q``. A constraint is satisfied when | ||
| the return value is <= 0. Multiple constraints on the same target are | ||
| combined via ``torch.max`` (most-violated semantics). | ||
| """ | ||
| if not self.outcome_constraints: | ||
| return [] | ||
|
|
||
| from collections import defaultdict | ||
|
|
||
| import torch | ||
|
|
||
| # Group constraints by target index in model output | ||
| all_quantities = list(self._modeled_quantity_names) | ||
| constraints_by_target: dict[int, list[OutcomeConstraint]] = defaultdict(list) | ||
| for constraint in self.outcome_constraints: | ||
| if constraint.target.name in all_quantities: | ||
| idx = all_quantities.index(constraint.target.name) | ||
| constraints_by_target[idx].append(constraint) | ||
|
|
||
| # Build one callable per target; combine multiple via max (most-violated) | ||
| constraint_callables: list[Callable[[torch.Tensor], torch.Tensor]] = [] | ||
| for target_idx, constraints in constraints_by_target.items(): | ||
| funcs = [c.to_botorch_constraint_func(target_idx) for c in constraints] | ||
| if len(funcs) == 1: | ||
| constraint_callables.append(funcs[0]) | ||
| else: | ||
| constraint_callables.append( | ||
| lambda s, f=funcs: torch.max( | ||
| torch.stack([fn(s) for fn in f]), dim=0 | ||
| )[0] | ||
| ) | ||
|
|
Scienfitz
left a comment
There was a problem hiding this comment.
thanks for tackling this important feature! It will take a while to review since it involves a brand new interface AND has potentially advanced logic. I have two minor upfront suggestions until a more detailed review
| ) | ||
| """Optional metadata containing description and other information.""" | ||
|
|
||
| outcome_constraints: tuple[OutcomeConstraint, ...] = field( |
There was a problem hiding this comment.
this attribute should just be named constraints
this would make it consistent that we have parameter-like constraints as part of SearchSpace.constraints and target-like constraints as part of Objective.constraints
(the classname for the constaint objects OutcomeConstraint is of course fine)
There was a problem hiding this comment.
I suggest to do the following:
I don't know when you branched this branch off of main, but we only recently added the AGENTS.md files (#769) which auto-inject instructions to achieve consistent code using agentic development. So if you haven't done that yet, please rebase this PR on main immediately and ask the agent to "replay" the commits with the new rules from AGENTS.md files in mind, the force push
Teaser
As a first step towards supporting outcome constraint in BayBE, this PR starts with
<=,>=,==on single targets)BotorchRecommenderSee the Roadmap section for full scope.
User entry-point
Outcome constraints are enabled by passing them to any
Objectivevia theoutcome_constraintskeyword argument:Design
Core design
The feature is introduced through three layers: the user-facing declaration API, the surrogate modeling layer, and the acquisition function layer.
1. User perspective — Declaring outcome constraints
OutcomeConstraintholds aNumericalTargetwithminimize=None(no optimization direction — modeled and constrained only)Objectiveviaoutcome_constraints=2. Surrogate model perspective — Fitting models for constraint targets
Constraint targets are included in surrogate modeling via
Objective. _modeled_quantities.Model output layout:
[opt_0, ..., opt_n, constraint_0, ...]_pre_transform()— pipes all targets through (surrogate training)transform()— evaluates optimization targets only (objective value)[n_candidates, n_opt + n_constraint]transform())[n_rows, n_opt]3. Acquisition function perspective — Steering toward feasibility
The callable (
f(samples) → Tensor, feasible ⟺ <= 0) is the interface betweenOutcomeConstraintand BoTorch._set_best_f()—best_ffrom feasible training points only (GP posterior); falls back to global max if none feasible.Additional design details
1. Compatibility check: Outcome constraints are only compatible with MC-based
acquisition functions. Analytic acqfs are blocked because
ScalarizedPosteriorTransformrequireslen(weights) == n_models, which breaks when constraint targets are appended.TwoPhaseRecommendercan still be used — the initial phase doesn't need acqf compatibility.constraints.2. No feasible measurements yet: When no training point satisfies the outcome
constraints,
best_ffalls back to the global maximum of the objective (ignoring constraints). The per-sample feasibility weighting (u × I) is relied upon to steer the search toward feasibility — the optimizer can still explore becausebest_fprovides a reference value, and infeasible samples have their utility zeroed. Alternatively, a dedicated mechanism that maximizes feasibility probability directly could be used to guide the search toward the feasible region first.Roadmap
<=,>=,==) on a single targetSingleTargetObjective,DesirabilityObjective,ParetoObjectiveBotorchRecommender(constraints enforced via acquisition function)constraintsparameter:qExpectedImprovement,qLogExpectedImprovement,qNoisyExpectedImprovement,qLogNoisyExpectedImprovement,qProbabilityOfImprovement,qExpectedHypervolumeImprovement,qLogExpectedHypervolumeImprovement,qNoisyExpectedHypervolumeImprovement,qLogNoisyExpectedHypervolumeImprovementconstraintsparameter:qUpperConfidenceBound,qSimpleRegret/qThompsonSampling,qKnowledgeGradient,qNegIntegratedPosteriorVariance- Analytic acquisition functions (future PR)
Future extensions
ConstrainedExpectedImprovement) natively support outcome constraints. Requires fixing the posterior transform to pad the weight vector to lengthn_modelsand scoping the analytic guard to only those acqfs that truly lack constraint supportf(samples) -> Tensorthat are feasible when <= 0temperature + pressure <= 200), requiring a different interface than the current single-targetOutcomeConstraintRandomRecommender,FPSRecommender, clustering recommenders) have no surrogate model and therefore cannot evaluate outcome constraints. Constraint enforcement fundamentally requires a predictive model. These recommenders will continue to ignore constraints with a warningAssumptions
No overlap between optimization targets and constraint targets. A target can't be both optimized and constrained. Theoretically, this restriction isn't necessary for MC-based acqfs (see Independence assumption in Theory below). Reasons for keeping it in this PR:
CompositeSurrogatesetting (independent GPs).Note: If we want to get rid of this assumption,
as_pre_transformationmust beFalseforDesirabilityObjectivewhen any target is both an optimization targetand a constraint target. This condition is needed to ensure each target gets its own
surrogate model, so the constraint callable can access the individual target's
posterior predictions.
ConstrainedExpectedImprovement)E[u] × P(c ≤ 0)CompositeSurrogate(independent GPs)Theory: Outcome constraints in MC-based BoTorch acquisition functions
How constraints are enforced in MC-based Botorch acquisition function?
From
SampleReducingMCAcquisitionFunction(BoTorch docstring):_sample_forward,In formula:
where
I(yᵢ) = σ(-c(yᵢ) / η)is a smooth feasibility indicator per sample. This isE[u × I], notE[u] × P(feasible)— weighting happens per sample beforeaveraging. A sample that violates constraints has its utility zeroed regardless of
other samples. --> It does not need any independence assumption.
Independence assumption
ConstrainedExpectedImprovement) may require independence.Why MC requires no independence: With a multi-output GP, a single MC sample
yᵢ = (temp_i, pressure_i)is drawn from the joint posterior —temp_iandpressure_iare already correlated within that sample. Then:I(temp_i ≤ 80 AND pressure_i ≤ 5) = I(c₁(temp_i) ≤ 0) × I(c₂ (pressure_i) ≤ 0)— just AND, always exact.P(temp ≤ 80 AND pressure ≤ 5) -> (1/N) Σ I(c₁ ≤ 0) × I(c₂ ≤ 0)—captures correlations in each pair of samples thanks for multi-output GP