Skip to content

Multiobjective optimization via functional operators API

The functional operators API of EvoTorch (evotorch.operators.functional) can be used for multiobjective optimization. In this notebook, we demonstrate how this functional operators API can be used to tackle the Kursawe function, which has two objectives to be minimized.


We begin with the necessary imports:

import torch

import evotorch.operators.functional as func_ops
from evotorch.decorators import rowwise

import matplotlib.pyplot as plt
import numpy as np

Below, we implement Kursawe's function.

Notice how we decorate the function via evotorch.decorators.rowwise. This @rowwise decorator allows us to implement the function f with the simple assumption that x is a single vector. However, when calling this decorated function f from outside, we will be able to provide x as a matrix, in which case the @rowwise decorator will broadcast the function f such that it will be applied for each row of the matrix. In fact, we can even give a tensor with 3 or more dimensions as x, and the decorated f will interpret all of the extra leftmost dimensions as the batch dimensions.

@rowwise
def f(x: torch.Tensor) -> torch.Tensor:
    # Kursawe's function

    f1 = torch.sum(
        -10 * torch.exp(
            -0.2 * torch.sqrt(x[0:2] ** 2.0 + x[1:3] ** 2.0)
        ),
    )

    f2 = torch.sum(
        (torch.abs(x) ** 0.8) + (5 * torch.sin(x ** 3)),
    )
    fitnesses = torch.hstack([f1, f2])
    return fitnesses

Below, we have the constants regarding the problem, and hyperparameters:

device = "cpu"
solution_length = 3
objective_sense = ["min", "min"]
lb = -5.0
ub = 5.0

popsize = 200
num_generations = 100
mutation_stdev = 0.03
tournament_size = 4

Initialize a population, and store it via the variable population:

population = (torch.rand(popsize, solution_length, device=device) * (ub - lb)) + lb
population.shape

Evaluate the initial population, and store the evaluation results within the variable evals:

evals = f(population)
evals.shape

Main loop of the optimization:

for generation in range(1, 1 + num_generations):

    # Apply a tournament selection, and a simulated binary cross-over (SBX)
    # on the selected parents
    candidates = func_ops.simulated_binary_cross_over(
        population,
        evals,
        tournament_size=tournament_size,
        eta=1,
        objective_sense=objective_sense,
    )

    # Instead of a simulated binary cross-over, we could also use a two-point
    # cross-over, as follows:
    #
    # candidates = func_ops.two_point_cross_over(
    #     population,
    #     evals,
    #     tournament_size=tournament_size,
    #     objective_sense=objective_sense,
    # )

    # Apply Gaussian mutation on the results of the cross-over operation
    candidates = candidates + (torch.randn_like(candidates) * mutation_stdev)

    # Evaluate the mutated candidate solutions
    candidate_evals = f(candidates)

    # Form an extended population by combining the parent solutions and the
    # candidate solutions.
    extended_population, extended_evals = func_ops.combine(
        (population, evals),
        (candidates, candidate_evals),
        # We are passing `objective_sense` to inform the `combine` function
        # that the problem at hand is multi-objective:
        objective_sense=objective_sense,
    )

    # Take the `popsize` number of solutions from best pareto-fronts.
    population, evals = func_ops.take_best(
        extended_population,
        extended_evals,
        popsize,
        objective_sense=objective_sense,
        # When selecting the solutions, we want the crowding distances of the
        # solutions to be taken into account:
        crowdsort=True
    )

    # Print the current status:
    print("Generation:", generation, "  Best evals of the population:", torch.max(evals, dim=0).values)

Considering that evals now stores the evaluation results of the latest population, we can take the best solutions belonging to the best pareto-front as follows:

# Compute domination count (i.e. how many times a solution was dominated)
# for each solution
dcounts = func_ops.domination_counts(evals, objective_sense=objective_sense)

# Make a mask in which the i-th element is True if the i-th solution of the
# population has never been dominated
# (i.e. if the i-th solution is at the best pareto-front)
at_best_front = (dcounts == 0)

# Filter both the decision values tensor and the evaluation results tensor
# such that only the solutions on the best pareto-front will be included.
# The results of this filtering operation will be stored by the variables
# `best_pop` and `best_evals`.
best_pop = population[at_best_front]
best_evals = evals[at_best_front]
best_pop.shape, best_evals.shape

Plot the fitnesses of the best solutions:

plt.title(f"Fitnesses after {num_generations} generations")
plt.scatter(best_evals[:, 0].cpu().numpy(), best_evals[:, 1].cpu().numpy())
plt.show()

Batched multiobjective optimization

The functional operators API of EvoTorch is written in such a way that a single call to an operator can work on not just a single population, but on a batch of multiple populations, in a vectorized manner.

Below, we demonstrate this feature by modifying the multiobjective example above.

# Let us consider 4 populations:
num_populations = 4

# Size for each population:
popsize = 200

# Shared hyperparameters
num_generations = 30
tournament_size = 4
mutation_stdev = 0.03

# Hyperparameters that vary for each population:
eta = torch.tensor([1.0, 8.0, 20.0, 40.0], device=device)
# Initialize a batch of populations (each population is initialized the same)
population = (torch.rand(popsize, solution_length, device=device) * (ub - lb)) + lb
evals = f(population)
broadcaster = torch.ones(num_populations, 1, 1, device=device)
population = population * broadcaster
evals = evals * broadcaster

# Alternatively, in some cases, you might want to do the initialization in
# such a way that each population within the batch is different:
#
# population = (torch.rand(num_populations, popsize, solution_length, device=device) * (ub - lb)) + lb
# evals = f(population)

population.shape, evals.shape
for generation in range(1, 1 + num_generations):
    candidates = func_ops.simulated_binary_cross_over(
        population,
        evals,
        tournament_size=tournament_size,
        objective_sense=objective_sense,
        #
        # Upon seeing that `eta` is given as a vector (instead of a scalar),
        # the function `simulated_binary_cross_over` will treat the `eta` as
        # a batch of hyperparameters. In more details, the first `eta` is
        # used on the first population of the batch, the second `eta` is used
        # on the second population of the batch, and so on...
        eta=eta,
    )

    candidates = candidates + (torch.randn_like(candidates) * mutation_stdev)
    candidate_evals = f(candidates)

    extended_population, extended_evals = func_ops.combine(
        (population, evals),
        (candidates, candidate_evals),
        objective_sense=objective_sense,
    )

    population, evals = func_ops.take_best(
        extended_population,
        extended_evals,
        popsize,
        objective_sense=objective_sense,
        crowdsort=True
    )

    # Print the current status:
    print("Generation:", generation)
    print("Best evals of the populations:")
    print(torch.max(evals, dim=1).values)
    print()

For each solution within each population, compute the domination count:

dcounts = func_ops.domination_counts(evals, objective_sense=objective_sense)
dcounts.shape

From each population, take the best pareto-front, and plot the fitnesses belonging to that pareto-front:

for i_population in range(num_populations):
    single_pop_dcounts = dcounts[i_population, :]
    single_pop = population[i_population, :, :]
    single_pop_evals = evals[i_population, :, :]

    at_best_front = (single_pop_dcounts == 0)

    best_pop = single_pop[at_best_front]
    best_evals = single_pop_evals[at_best_front]

    plt.title(f"Fitnesses with eta={float(eta[i_population].cpu())}, after {num_generations} generations")
    plt.scatter(best_evals[:, 0].cpu().numpy(), best_evals[:, 1].cpu().numpy())
    plt.show()

See this notebook on GitHub