Skip to content

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.

result = optimize(
    config, INITIAL_VALUES, rosenbrock, report=report, constraint_tolerance=1e-6
)

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