Multi-objective optimization

Amplify-BBOpt provides two approaches for multi-objective optimization:

Single-objective aggregation

Direct multi-objective optimization

Class

Optimizer

MultiOptimizer

Black-box return type

float

list[float]

Surrogate model approximates

Weighted sum of objectives (aggregated value)

Each objective value (before aggregation)

Best suited for

Fixed weights between objectives

Dynamically adjusting weights per iteration

Single-objective aggregation

Combine multiple objectives into a single float value via a weighted sum, and optimize using the standard Optimizer. Because the surrogate model directly approximates the weighted sum, it becomes specialized for that weight configuration. This approach is best suited when the weights are predetermined and will not change.

from amplify_bbopt import blackbox, KMTrainer, Optimizer, RealVariable

# Weights for each objective
w_g = 10
w_h = 2

@blackbox
def bbfunc(
    x: float = RealVariable((0, 1)),
    y: float = RealVariable((0, 1)),
    z: float = RealVariable((0, 1)),
) -> float:
    return w_g * g(x, y) + w_h * h(y, z)

optimizer = Optimizer(
    blackbox=bbfunc,
    trainer=KMTrainer(),
    client=my_client,
)
optimizer.add_random_training_data(num_data=10)
optimizer.optimize(10)

Note

With this approach, a single surrogate model approximates the weighted sum. If the weights change, past training data no longer represents the new weighted-sum target, so weight changes must be made with care.

Direct multi-objective optimization

By combining a black-box function that returns list[float] with MultiOptimizer, each objective is approximated by its own independent surrogate model. Because each surrogate model is trained independently for its own objective, you can learn the shape of each objective separately without letting a dominant objective adversely affect the others’ surrogate models. Also, since objective values are stored separately, you can change weights via weight at each iteration without collecting the training data again (see Dynamic weight adjustment for details).

Defining the black-box function

For multi-objective optimization, the black-box function should return list[float].

from amplify_bbopt import blackbox, IntegerVariable

@blackbox
def my_multi_blackbox_func(
    x0: int = IntegerVariable((0, 10)),
    x1: int = IntegerVariable((0, 10)),
) -> list[float]:
    y0 = simulator0(x0, x1)
    y1 = simulator1(x0, x1)
    return [y0, y1]

The black-box function above handles the case where multiple objectives share common decision variables. For other dependencies between objectives, see Dependencies between objective functions.

Tip

If you want to set static weights between objectives, include the weights directly in the return value of the black-box function.

@blackbox
def my_multi_blackbox_func(...):
    y0 = simulator0(...)
    y1 = simulator1(...)
    return [2.0 * y0, y1]  # y0 weighted twice as heavily

In this case, best comparison is also based on the scaled values (in the example above, 2.0 * y0 and y1).

To dynamically change weights as optimization progresses, see Dynamic weight adjustment.

Instantiating MultiOptimizer

Use MultiOptimizer for multi-objective optimization. Pass a list of Trainer instances to trainer — one per objective. Each element must be a distinct instance; reusing the same instance across objectives raises a ValueError.

from amplify_bbopt import KMTrainer, MultiOptimizer

optimizer = MultiOptimizer(
    blackbox=my_multi_blackbox_func,
    trainer=[KMTrainer(), KMTrainer()],  # one Trainer per objective
    client=my_client,
)

Caution

Trainer is an object that updates its internal state on each training call. Reusing the same instance across objectives causes training data from one objective to mix into another, corrupting the surrogate models.

Caution

As with single-objective optimization, you can specify a data transformation for each objective’s training data via surrogate_data_transformer. For multi-objective, pass a list of DataTransformer instances — one per objective. Each element must be a distinct instance; reusing the same instance raises a ValueError.

from amplify_bbopt import ExpScaler, KMTrainer, MultiOptimizer

optimizer = MultiOptimizer(
    blackbox=my_multi_blackbox_func,
    trainer=[KMTrainer(), KMTrainer()],
    client=my_client,
    surrogate_data_transformer=[ExpScaler(), ExpScaler()],  # one DataTransformer per objective
)

Pass None for any objective that does not need a transformation.

optimizer = MultiOptimizer(
    blackbox=my_multi_blackbox_func,
    trainer=[KMTrainer(), KMTrainer()],
    client=my_client,
    surrogate_data_transformer=[ExpScaler(), None],  # transform first objective only
)

Running optimization

The basic flow for adding initial training data and running optimization is the same as single-objective. However, when passing existing data via training_data, the shape of y differs: for single-objective, y must be a 1D array of shape (n_samples,), but for multi-objective it must be a 2D array of shape (n_samples, n_objectives).

# Add initial random training data
optimizer.add_random_training_data(num_data=10)

# Run the optimization cycles
optimizer.optimize(10)
import numpy as np
from amplify_bbopt import Dataset

data_x = ...  # shape: (n_samples, n_variables)
data_y = ...  # shape: (n_samples, n_objectives)  ← 2D

optimizer = MultiOptimizer(
    blackbox=my_multi_blackbox_func,
    trainer=[KMTrainer(), KMTrainer()],
    client=my_client,
    training_data=Dataset(data_x, data_y),
)

Retrieving optimization results

As with single-objective, use best to retrieve the best solution found. For multi-objective, objective is a list[float] — one value per objective.

print(optimizer.best.values)    # Best solution (input values)
print(optimizer.best.objective) # Objective value: list[float]

Note

The best solution is the one that minimizes the weighted sum of objective values — each value multiplied by its surrogate model’s weight (weight). When weights are fixed (including the default of 1.0), this criterion is meaningful. However, when weights are adjusted dynamically, each iteration uses different weights yet best re-evaluates all samples with the current weights only, so comparisons may not be consistent across iterations (see Best solution when using dynamic weights for details).