Skip to content

Ensemble-Based Optimization

The Quickstart minimized a single, fixed objective. Here we work through an uncertain problem: the objective depends on parameters we do not know exactly. We now have a set of functions, each with different parameters drawn from some (possibly unknown) probability distribution. Each member of the set is a realization. The full runnable script is examples/simple/ensemble.py.

ropt optimizes the realizations together by combining them into a single robust objective — by default a weighted average over the realizations. Minimizing the average yields a solution that performs well across the whole set rather than for one particular case. We minimize the Rosenbrock function, generalized to \(n\) variables, with coefficients that vary between realizations.

For realization \(i\), with coefficients \(a_i\) and \(b_i\), the per-realization objective is:

\[ f_i(\mathbf{x}) = \sum_{k=1}^{n-1} \left[ (a_i - x_k)^2 + b_i \left( x_{k+1} - x_k^2 \right)^2 \right] \]

which it reduces to the standard Rosenbrock function when \(n = 1\), and \(a_i = 1, b_i = 100\) for every realization.

ropt combines the realizations into the robust objective:

\[ f(\mathbf{x}) = \sum_i w_i f_i(\mathbf{x}), \]

with weights \(w_i\) that we set in the configuration below.

1. Describe the problem

The config adds a realizations section: the weights list has one entry per realization that sets how much each contributes to the combined objective:

DIM = 5
REALIZATIONS = 10
UNCERTAINTY = 0.1
CONFIG: dict[str, Any] = {
    "variables": {
        "variable_count": DIM,
        "perturbation_magnitudes": 1e-6,
    },
    "realizations": {
        "weights": [1.0] * REALIZATIONS,
    },
}
INITIAL_VALUES = 2 * np.arange(DIM) / DIM + 0.5

The weights need not sum to one; ropt normalizes them. Equal weights, as here, give a plain average. See Configuration for the other realization settings. INITIAL_VALUES is the point the optimization starts from.

2. Draw the uncertain parameters

Each realization is one draw of the uncertain parameters. Here the two Rosenbrock coefficients are sampled once per realization, so that a[r] and b[r] are the coefficients for realization r:

rng = default_rng(seed=123)
a = rng.normal(loc=1.0, scale=UNCERTAINTY, size=REALIZATIONS)
b = rng.normal(loc=100.0, scale=100 * UNCERTAINTY, size=REALIZATIONS)

3. Write the evaluation function

ropt calls the evaluation function once for every realization at each point it evaluates, so it must return the value for its own realization. The second argument tells it which one: context.realization is the realization number, which we use to index the parameter arrays:

def rosenbrock(
    variables: NDArray[np.float64], context: EvaluationFunctionContext
) -> float:
    """The Rosenbrock function of one realization, with uncertain coefficients.

    Args:
        variables: The variable vector to evaluate.
        context:   Identifies the realization being evaluated.

    Returns:
        The objective of this realization at `variables`.
    """
    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
    return float(objective)

The Quickstart ignored this second argument; an ensemble objective uses context.realization to select the parameters for the realization it is computing. ropt combines the per-realization values into the robust objective for you.

Returning a single number, as here, is the simplest case. A function that has multiple objectives and has constraints returns a sequence instead — the objectives first, then the constraints; see Constraints.

4. Follow the progress (optional)

To track a running optimization, pass a report callback: ropt calls it after every evaluation, with an EvaluateResult describing what was just computed.

def report(result: EvaluateResult) -> None:
    """Print the objective of each evaluation as the run proceeds.

    Args:
        result: The result of a single function evaluation.
    """
    if result.target_objective is not None:
        print(f"  objective: {result.target_objective}")

The callback belongs to one run and sees one evaluation at a time. To keep the results rather than just look at them — or to collect them across several runs — use a handler instead; see Collecting Results with Handlers.

5. Run it

The call is the same as for a deterministic problem, with INITIAL_VALUES the start point defined above:

result = optimize(CONFIG, INITIAL_VALUES, rosenbrock, report=report)

ropt evaluates all ten realizations at each point and averages them into the robust objective, which is what it optimizes.

6. Read the result

optimize returns an OptimizeResult:

print(f"exit code:         {result.exit_code}")
print(f"optimal variables: {result.variables}")
print(f"optimal objective: {result.target_objective}")
  • result.variables is the best set of variables found, and result.target_objective the robust objective value there. Both are None if the run produced no valid result.
  • result.exit_code says why the run stopped (a member of the ExitCode enumeration).
  • result.results holds the full low-level result (a FunctionResults object), if you need every detail.

See The result for the remaining fields.

Because the coefficients are centered on the values used in the Quickstart, the robust optimum still lies close to where all variables equal 1 — but it minimizes the average over the uncertain coefficients rather than any single realization.

Where to next