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/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. For
nonlinear constraints, you can also choose the scales yourself from a
preliminary evaluation instead of using auto_scale; see
Choose scales from a preliminary evaluation.
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, even when optional labels are configured: 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 = optimize(
config,
INITIAL_VALUES,
rosenbrock,
result_callback=report,
constraint_tolerance=1e-6,
)
result.results is only ever the best
feasible evaluation; if none satisfied the constraints to within the
tolerance, the optimization returns None instead of a best result. A tolerance
that is too tight is a common reason for an empty result — see
Troubleshooting.
To watch feasibility as the optimization proceeds, read constraint_info from
the result the result callback receives. Its nonlinear_violation is zero
where a constraint is met, positive by the amount it is exceeded, and NaN where
the constraint value is NaN:
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.