Skip to content

Add outcome constraints#792

Open
Waschenbacher wants to merge 6 commits into
mainfrom
feature/outcome-constraint
Open

Add outcome constraints#792
Waschenbacher wants to merge 6 commits into
mainfrom
feature/outcome-constraint

Conversation

@Waschenbacher
Copy link
Copy Markdown
Collaborator

Teaser

As a first step towards supporting outcome constraint in BayBE, this PR starts with

  • simple threshold-based outcome constraints (<=, >=, == on single targets)
  • BotorchRecommender
  • A subset of MC-based BoTorch acquisition functions

See the Roadmap section for full scope.

User entry-point

Outcome constraints are enabled by passing them to any Objective via the outcome_constraints keyword argument:

import numpy as np, pandas as pd
from baybe.constraints.outcome import OutcomeConstraint
from baybe.objectives.single import SingleTargetObjective
from baybe.parameters.numerical import NumericalDiscreteParameter
from baybe.recommenders.pure.bayesian.botorch import BotorchRecommender
from baybe.searchspace import SearchSpace
from baybe.targets.numerical import NumericalTarget

# Setup
searchspace = SearchSpace.from_product(
    parameters=[NumericalDiscreteParameter(name="x", values=list(range(1, 21)))]
)
yield_target = NumericalTarget("yield", minimize=False)
temp_target = NumericalTarget("temperature", minimize=None)  # constraint-only

# Training data: yield and temperature both increase with x
xs = np.linspace(1, 20, 15)
noise = np.random.default_rng(42).normal(0, 1, 15)
measurements = pd.DataFrame(
    {"x": xs, "yield": 5 * xs + noise, "temperature": 6 * xs + 20 + noise}
)

# Recommend with and without constraint
recommender = BotorchRecommender()
objectives = {
    "unconstrained": SingleTargetObjective(target=yield_target),
    "constrained":   SingleTargetObjective(
        target=yield_target,
        outcome_constraints=(OutcomeConstraint(temp_target, "<=", 60.0),),
    ),
}
for label, obj in objectives.items():
    rec = recommender.recommend(5, searchspace, obj, measurements)
    print(f"{label}: mean x = {rec['x'].mean():.1f}")
# unconstrained: mean x ≈ 16.4 — chases high yield
# constrained:   mean x ≈ 7.0  — pulled toward feasible region

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

  • OutcomeConstraint holds a NumericalTarget with minimize=None (no optimization direction — modeled and constrained only)
  • Passed to Objective via outcome_constraints=
  • The objective partitions targets into two disjoint roles:
┌─────────────────────────────────────────────────────────────────────────┐
│                              Objective                                   │
│                                                                          │
│  outcome_constraints = [OutcomeConstraint(target=temp, op="<=", val=80)] │
│                                │                                         │
│                                ▼                                         │
│  ┌────────────────────────┐   ┌──────────────────────────────────┐      │
│  │ _optimization_targets  │   │ constraint_targets               │      │
│  │                        │   │ (extracted from OutcomeConstraint │      │
│  │ • has direction        │   │  objects)                         │      │
│  │   (MAX / MIN)          │   │                                  │      │
│  │ • drives obj value     │   │ • minimize=None (no direction)   │      │
│  │                        │   │ • modeled by surrogate           │      │
│  │ e.g. "yield" (MAX)     │   │ • excluded from obj value        │      │
│  │                        │   │                                  │      │
│  │                        │   │ e.g. "temperature"               │      │
│  └────────────────────────┘   └──────────────────────────────────┘      │
│                                                                          │
│  targets = _optimization_targets ∪ constraint_targets                    │
│  (determines required measurement columns)                               │
└─────────────────────────────────────────────────────────────────────────┘

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)
Stage Output shape Includes constraint targets?
Surrogate [n_candidates, n_opt + n_constraint] Yes — all modeled quantities
Objective (transform()) [n_rows, n_opt] No — optimization targets only

3. Acquisition function perspective — Steering toward feasibility

OutcomeConstraint ──► to_botorch_constraints() ──► callable ──► MC acqf
                                                  (interface)

The callable (f(samples) → Tensor, feasible ⟺ <= 0) is the interface between OutcomeConstraint and BoTorch.

  • _set_best_f()best_f from 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 ScalarizedPosteriorTransform requires len(weights) == n_models, which breaks when constraint targets are appended.

  • Layer 1 — recommender-level warning: only a warning (not error) because TwoPhaseRecommender can still be used — the initial phase doesn't need acqf compatibility.
  • Layer 2 — acqf builder error: hard guard at the point of use when the acqf signature lacks constraints.

2. No feasible measurements yet: When no training point satisfies the outcome
constraints, best_f falls 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 because best_f provides 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

Dimension What this PR delivers Not yet supported
Constraint types Simple threshold operators (<=, >=, ==) on a single target General callables (future PR), multi-target constraints
Objective types All: SingleTargetObjective, DesirabilityObjective, ParetoObjective
Recommenders BotorchRecommender (constraints enforced via acquisition function) All non-Bayesian recommenders ignore constraints with a warning. Meta recommenders delegate to sub-recommenders
Acquisition functions MC-based with constraints parameter: qExpectedImprovement, qLogExpectedImprovement, qNoisyExpectedImprovement, qLogNoisyExpectedImprovement, qProbabilityOfImprovement, qExpectedHypervolumeImprovement, qLogExpectedHypervolumeImprovement, qNoisyExpectedHypervolumeImprovement, qLogNoisyExpectedHypervolumeImprovement - MC acqfs without constraints parameter: qUpperConfidenceBound, qSimpleRegret/qThompsonSampling, qKnowledgeGradient, qNegIntegratedPosteriorVariance
- Analytic acquisition functions (future PR)

Future extensions

Extension Feasibility Description
Analytic acquisition functions Feasible Certain analytic acqfs (e.g., ConstrainedExpectedImprovement) natively support outcome constraints. Requires fixing the posterior transform to pad the weight vector to length n_models and scoping the analytic guard to only those acqfs that truly lack constraint support
General callable constraints Feasible Allow users to pass arbitrary constraint functions f(samples) -> Tensor that are feasible when <= 0
Multi-target constraints not sure Constraints referencing multiple targets simultaneously (e.g., temperature + pressure <= 200), requiring a different interface than the current single-target OutcomeConstraint
Non-Bayesian recommender enforcement Not sure Non-predictive recommenders (RandomRecommender, 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 warning

Assumptions

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:

  • My laziness - starting simple.
  • Do we have clear use case?
  • Analytic acqfs assume independence of optimization targets and constraint targets.
  • Consistent with the default CompositeSurrogate setting (independent GPs).

Note: If we want to get rid of this assumption, as_pre_transformation must be
False for DesirabilityObjective when any target is both an optimization target
and 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.

Component Layer Independence assumed?
BoTorch MC acqf formula Acquisition function No
BoTorch analytic acqf (e.g., ConstrainedExpectedImprovement) Acquisition function Yes — uses E[u] × P(c ≤ 0)
Multi-output GP (joint posterior) Surrogate model No — correlations captured in samples
CompositeSurrogate (independent GPs) Surrogate model Yes — modeling choice, not framework requirement

Theory: Outcome constraints in MC-based BoTorch acquisition functions

How constraints are enforced in MC-based Botorch acquisition function?

From SampleReducingMCAcquisitionFunction (BoTorch docstring):

  • (1) computing the unconstrained utility for each MC sample using _sample_forward,
  • (2) weighing the utility values by the constraint indicator per MC sample, and
  • (3) reducing (e.g. averaging) the weighted utility values over the MC dimension.

In formula:

acqf(x) = sample_reduction[ q_reduction[ u(yᵢ) × I(yᵢ) ] ]
         = E[ u(y) × I(feasible | y) ]

where I(yᵢ) = σ(-c(yᵢ) / η) is a smooth feasibility indicator per sample. This is
E[u × I], not E[u] × P(feasible) — weighting happens per sample before
averaging. A sample that violates constraints has its utility zeroed regardless of
other samples. --> It does not need any independence assumption.

Independence assumption

  • Whether target variables are independent is a surrogate model choice (single-output GP vs multi-output GP), not an acquisition function requirement.
  • MC-based BoTorch acquisition functions do not require independence between targets.
  • Attention: Analytic acquisition functions that support outcome constraints (e.g.,
    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_i and pressure_i are already correlated within that sample. Then:

  • On sample-level: I(temp_i ≤ 80 AND pressure_i ≤ 5) = I(c₁(temp_i) ≤ 0) × I(c₂ (pressure_i) ≤ 0) — just AND, always exact.
  • Averaged: 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

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
Copilot AI review requested due to automatic review settings May 8, 2026 07:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 OutcomeConstraint API and integrated outcome constraints into Objective target/modeling semantics (opt targets vs constraint targets).
  • Extended BoTorch acquisition builder to pass constraints callables (when supported) and to compute best_f using 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.

Comment on lines +25 to +33
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."""

Comment thread baybe/objectives/base.py
Comment on lines +63 to +82
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}."
)
Comment on lines +363 to +374
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
Comment thread baybe/objectives/base.py
Comment on lines +350 to +388
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 Scienfitz added this to the Roadmap (no ETA) milestone May 8, 2026
@Scienfitz Scienfitz linked an issue May 8, 2026 that may be closed by this pull request
Copy link
Copy Markdown
Collaborator

@Scienfitz Scienfitz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread baybe/objectives/base.py
)
"""Optional metadata containing description and other information."""

outcome_constraints: tuple[OutcomeConstraint, ...] = field(
Copy link
Copy Markdown
Collaborator

@Scienfitz Scienfitz May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Outcome constraints

3 participants