Constraints
A constraint restricts which variable vectors count as a valid solution. ropt
distinguishes three kinds, and they differ in 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.
Constraint values can also be rescaled before the optimizer sees them; see
Scaling constraints.
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 Running Optimizations 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.
result.results is only ever the best
feasible evaluation; if none satisfied the constraints to within the
tolerance, the run returns None instead of a best result. 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 result
the report callback receives. Its nonlinear_violation is zero where a
constraint is met and positive by the amount it is exceeded:
def report(result: FunctionResults) -> None:
"""Print any constraint violation, and the point that caused it.
Args:
result: The result of a single function evaluation.
"""
info = result.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, and Scaling the
equations for rescaling a
row before it reaches the optimizer.