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/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 lenght of the weights entry is equal to the number of realizations in the ensemble. 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, it must be a numpy vector of length equal to variable_count.

2. Draw the uncertain parameters

Each realization is one draw of the uncertain parameters. Here the two Rosenbrock coefficients are sampled from a normal distribution, 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 for every realization at each point it evaluates, so it must return the value for a given 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)

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.

Returning a single number 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 result 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 optimization 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 optimization and sees one evaluation at a time. See Using a result callback for what you can do with it. To keep the results rather than just look at them — or to collect them across several optimizations — use a handler instead; see Using handlers.

5. Run it

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

result = optimize(CONFIG, INITIAL_VALUES, rosenbrock, result_callback=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.message}")
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 optimization stopped (a member of the ExitCode enumeration).
  • result.results is the best evaluation of the optimization, a FunctionResults carrying every value that evaluation produced — the same object a handler receives. It is None if the optimization 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.

See also