Constraints
A constraint restricts which variable vectors count as a valid solution. ropt
distinguishes three kinds, and the difference that matters in practice is who
computes them:
| Kind | Declared | Computed by |
|---|---|---|
| Bounds | variables.lower_bounds / upper_bounds |
nobody — the optimizer never leaves the box |
| Linear | linear_constraints |
ropt, from the coefficients you give |
| Nonlinear | nonlinear_constraints |
your evaluation function, once per realization |
Bounds and linear constraints are deterministic: they are fully described by the
configuration, so ropt can evaluate them itself. A nonlinear constraint is not
— it is computed alongside the objective, which means it can be as expensive as
the objective and, in an ensemble, can differ between realizations.
The runnable script for this page is examples/simple/constrained.py. It adds a nonlinear constraint to the ensemble Rosenbrock problem of Ensemble-Based Optimization.
Note
Not every optimization method supports every kind of constraint. ropt
reports an error when the configured method cannot handle what you declared;
see Optimizer Backends for what each method
supports.
Declaring bounds and nonlinear constraints
Both live in the configuration, next to the variables:
CONFIG: dict[str, Any] = {
"variables": {
"variable_count": DIM,
"perturbation_magnitudes": 1e-6,
"lower_bounds": -5.0,
"upper_bounds": 5.0,
},
"realizations": {
"weights": [1.0] * REALIZATIONS,
},
"nonlinear_constraints": {
"lower_bounds": -np.inf,
"upper_bounds": -1.0,
},
}
Bounds keep every variable in \([-5, 5]\). The nonlinear_constraints section
declares how many nonlinear constraints there are and what range each value
must fall in — here a single constraint that must stay at or below \(-1\). Use
-np.inf or np.inf for a one-sided constraint, and equal lower and upper
bounds for an equality.
The section declares only the bounds; the values themselves come from the
evaluation function. See
nonlinear_constraints and
variables for the full field reference.
Returning a constraint from the evaluation function
An evaluation function that has constraints returns a sequence instead of a single number: the objectives first, then the constraints. With one objective and one constraint, that is a two-element list:
def rosenbrock(
variables: NDArray[np.float64], context: EvaluationFunctionContext
) -> list[float]:
"""The Rosenbrock objective and nonlinear constraint for one realization.
Args:
variables: The variable vector to evaluate.
context: Identifies the realization being evaluated.
Returns:
The objective followed by the constraint value for the realization.
"""
r = context.realization
objective = 0.0
for d_idx in range(DIM - 1):
x, y = variables[d_idx : d_idx + 2]
objective += (A[r] - x) ** 2 + B[r] * (y - x * x) ** 2
x, y = variables[:2]
constraint = (x - A[r]) ** 3 - y
return [float(objective), float(constraint)]
The order is positional — there are no names — so it must match the order in which the objectives and constraints are configured. See The evaluation function for the other shapes the return value can take.
Because the constraint is computed per realization and uses A[r], it is
stochastic: each realization constrains the problem slightly differently, and
ropt combines them the same way it combines the objectives.
Deciding when a constraint is satisfied
A constraint is rarely met exactly, so optimize takes a
constraint_tolerance: a result counts as feasible when no constraint is
violated by more than that amount.
This matters more than it looks. result.variables is only ever the best
feasible point; if no evaluation satisfied the constraints to within the
tolerance, the run returns None instead of a best point. A tolerance that is
too tight is a common reason for an empty result — see
Common Pitfalls.
To watch feasibility as the run proceeds, read constraint_info from the full
result. Its nonlinear_violation is zero where a constraint is met and positive
by the amount it is exceeded:
def report(result: EvaluateResult) -> None:
"""Print any constraint violation, and the point that caused it.
Args:
result: The result of a single function evaluation.
"""
assert result.results is not None
info = result.results.constraint_info
if (
info is not None
and info.nonlinear_violation is not None
and np.any(info.nonlinear_violation > 0)
):
print(f" constraint violation: {info.nonlinear_violation}")
print(f" at variables: {result.variables}")
Adding a linear constraint
A linear constraint is a row of coefficients applied to the variable vector, with bounds on the result. It never reaches your evaluation function. Equal lower and upper bounds make it an equality — this one forces the fourth and fifth variables to be equal:
config["linear_constraints"] = {
"coefficients": [[0.0, 0.0, 0.0, 1.0, -1.0]],
"lower_bounds": 0.0,
"upper_bounds": 0.0,
}
The script adds it when run with --linear. One row per constraint, one
coefficient per variable; see
linear_constraints for the field
reference.
Where to next
- The fields used here, in full: Configuration.
- What the evaluation function may return: Running Optimizations.
- Why a run can end without a best point: Common Pitfalls.