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 a sum of \(n\) terms over \(n + 1\) 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} \left[ (a_i - x_k)^2 + b_i \left( x_{k+1} - x_k^2 \right)^2 \right] \]

which reduces to the standard Rosenbrock function of the Quickstart when \(n = 1\), with \(a_i = 1\) and \(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 Sections 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 identifies 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 with multiple objectives, or with 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 the FunctionResults describing what was just computed.

def report(result: FunctionResults) -> 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 OptimizationResult:

print(f"exit code:         {result.exit_code}")
if result.results is not None:
    print(f"optimal variables: {result.results.variables}")
    print(f"optimal objective: {result.results.target_objective}")
  • result.exit_code indicates why the run stopped (a member of the ExitCode enumeration).
  • result.results is the best evaluation the run reached, a FunctionResults carrying every value that evaluation produced — the same object a handler receives. It is None if the run produced no valid result, so one check covers every value read from it.
  • result.results.variables is the best set of variables found, and result.results.target_objective the robust objective value there.

That evaluation carries much more than those two values — the per-realization objectives, the aggregates, the ensemble weights; see Working with Results.

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.

See also