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:
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:
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:
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_codeindicates why the optimization stopped (a member of theExitCodeenumeration).result.resultsis the best evaluation of the optimization, aFunctionResultscarrying every value that evaluation produced — the same object a handler receives. It isNoneif the optimization produced no valid result, so one check covers every value read from it.result.results.variablesis the best set of variables found, andresult.results.target_objectivethe 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
- The ideas and terms behind ensembles: Key Concepts.