Example on the usage of NIMBUS

This notebook will go through a simple example to illustrate how the synchronous variant of NIMBUS has been implemented in the DESDEO framework.

We will be solving the Kursawe function originally defined in this article.

Let us begin by importing some libraries and defining the problem.

[1]:
import numpy as np

import matplotlib.pyplot as plt
from desdeo_problem.problem import MOProblem
from desdeo_problem.problem import variable_builder
from desdeo_problem.problem import _ScalarObjective

def f_1(xs: np.ndarray):
    xs = np.atleast_2d(xs)
    xs_plusone = np.roll(xs, 1, axis=1)
    return np.sum(-10*np.exp(-0.2*np.sqrt(xs[:, :-1]**2 + xs_plusone[:, :-1]**2)), axis=1)

def f_2(xs: np.ndarray):
    xs = np.atleast_2d(xs)
    return np.sum(np.abs(xs)**0.8 + 5*np.sin(xs**3), axis=1)


varsl = variable_builder(
    ["x_1", "x_2", "x_3"],
    initial_values=[0, 0, 0],
    lower_bounds=[-5, -5, -5],
    upper_bounds=[5, 5, 5],
)

f1 = _ScalarObjective(name="f1", evaluator=f_1)
f2 = _ScalarObjective(name="f2", evaluator=f_2)

problem = MOProblem(variables=varsl, objectives=[f1, f2], ideal=np.array([-20, -12]), nadir=np.array([-14, 0.5]))

To check out the problem, let us compute a representation of the Pareto optimal front of solutions:

[2]:
from desdeo_mcdm.utilities.solvers import solve_pareto_front_representation

p_front = solve_pareto_front_representation(problem, step=1.0)[1]

plt.scatter(p_front[:, 0], p_front[:, 1], label="Pareto front")
plt.scatter(problem.ideal[0], problem.ideal[1], label="Ideal")
plt.scatter(problem.nadir[0], problem.nadir[1], label="Nadir")
plt.xlabel("f1")
plt.ylabel("f2")
plt.title("Approximate Pareto front of the Kursawe function")
plt.legend()
plt.show()
../_images/notebooks_synchronous_nimbus_3_0.png

Now we can get to the NIMBUS part. Let us define an instance of the NIMBUS method utilizing our problem defined earlier, and start by invoking the instance’s start method:

[ ]:
from desdeo_mcdm.interactive.NIMBUS import NIMBUS

method = NIMBUS(problem, "scipy_de")

classification_request, plot_request = method.start()

Let us look at the keys in the dictionary contained in the classification_request:

[4]:
print(classification_request.content.keys())
dict_keys(['message', 'objective_values', 'classifications', 'levels', 'number_of_solutions'])

Message should give us some more information:

[5]:
print(classification_request.content["message"])
Please classify each of the objective values in one of the following categories:
        1. values should improve '<'
        2. values should improve until some desired aspiration level is reached '<='
        3. values with an acceptable level '='
        4. values which may be impaired until some upper bound is reached '>='
        5. values which are free to change '0'
Provide the aspiration levels and upper bounds as a vector. For categories 1, 3, and 5,the value in the vector at the objective's position is ignored. Supply also the number of maximumsolutions to be generated.

We should therefore classify each of the objectives found behind the objective_values -key in the dictionary in classification_request.content. Let’s print them:

[6]:
print(classification_request.content["objective_values"])
[-15.67493201  -7.67493202]

Instead of printing the values, we could have also used the plot_request object. However, we are inspecting only one set of objective values for the time being, so a raw print of the values should be enough. Let us classify the objective values next. We can get a hint of what the classification should look like by inspecting the value found using the classifications -key in classification_request.content:

[7]:
print(classification_request.content["classifications"])
[None]

Therefore it should be a list. Suppose we wish to improve (decrease in value) the first objective, and impair (increase in value) the second objective till some upper bound is reached. We should define our preferences as a dictionary classification_request.response with the keys classifications and number_of_solutions (we have to define the number of new solutions we wish to compute). The key levels will contain the upper bound for the second objective.

[8]:
response = {
    "classifications": ["<", ">="],
    "number_of_solutions": 3,
    "levels": [0, -5]
}
classification_request.response = response

To continue, just feed classification_request back to the method through the step method:

[9]:
save_request, plot_request = method.iterate(classification_request)

We got a new request as a response. Let us inspect it:

[10]:
print(save_request.content.keys())
print(save_request.content["message"])
print(save_request.content["objectives"])
dict_keys(['message', 'solutions', 'objectives', 'indices'])
Please specify which solutions shown you would like to save for later viewing. Supply the indices of such solutions as a list, or supply an empty list if none of the shown solutions should be saved.
[array([-16.66436223,  -5.00892336]), array([-1.99999999e+01,  1.21120356e-06]), array([-18.47503268,  -1.82299996])]

Suppose the first and last solutions result in nice objective values.

[11]:
response = {"indices": [0, 2]}
save_request.response = response

intermediate_request, plot_request = method.iterate(save_request)
[12]:
print(intermediate_request.content.keys())
print(intermediate_request.content["message"])
dict_keys(['message', 'solutions', 'objectives', 'indices', 'number_of_desired_solutions'])
Would you like to see intermediate solutions between two previously computed solutions? If so, please supply two indices corresponding to the solutions.

We do not desire to see intermediate results.

[13]:
response = {"number_of_desired_solutions": 0, "indices": []}
intermediate_request.response = response

preferred_request, plot_request = method.iterate(intermediate_request)
[14]:
print(preferred_request.content.keys())
print(preferred_request.content["message"])
dict_keys(['message', 'solutions', 'objectives', 'index', 'continue'])
Please select your most preferred solution and whether you would like to continue.

We should select our most preferred solution. Let us plot the objective values to inspect them better:

[15]:
plt.scatter(p_front[:, 0], p_front[:, 1], label="Pareto front")
plt.scatter(problem.ideal[0], problem.ideal[1], label="Ideal")
plt.scatter(problem.nadir[0], problem.nadir[1], label="Nadir")
for i, z in enumerate(preferred_request.content["objectives"]):
    plt.scatter(z[0], z[1], label=f"solution {i}")
plt.xlabel("f1")
plt.ylabel("f2")
plt.title("Approximate Pareto front of the Kursawe function")
plt.legend()
plt.show()
../_images/notebooks_synchronous_nimbus_27_0.png

Solutions at indices 0 and 2 seem to be overlapping in the objective space. We decide to select the solution at index 1, and to continue the iterations.

[16]:
response = {"index": 1, "continue": True}
preferred_request.response = response

classification_request, plot_request = method.iterate(preferred_request)

Back at the classification phase of the NIMBUS method.

[17]:
response = {
    "classifications": [">=", "<"],
    "number_of_solutions": 4,
    "levels": [-16, -1]
}
classification_request.response = response

save_request, plot_request = method.iterate(classification_request)

Let us plot some of the solutions again:

[18]:
plt.scatter(p_front[:, 0], p_front[:, 1], label="Pareto front")
plt.scatter(problem.ideal[0], problem.ideal[1], label="Ideal")
plt.scatter(problem.nadir[0], problem.nadir[1], label="Nadir")
for i, z in enumerate(save_request.content["objectives"]):
    plt.scatter(z[0], z[1], label=f"solution {i}")
plt.xlabel("f1")
plt.ylabel("f2")
plt.title("Approximate Pareto front of the Kursawe function")
plt.legend()
plt.show()
../_images/notebooks_synchronous_nimbus_33_0.png

NIMBUS really took to heart our request to detoriate the first objective… Suppose we like all of the solutions:

[19]:
response = {"indices": [0, 1, 2, 3]}
save_request.response = response

intermediate_request, plot_request = method.iterate(save_request)

Let us plot everything we have so far:

[20]:
plt.scatter(p_front[:, 0], p_front[:, 1], label="Pareto front")
plt.scatter(problem.ideal[0], problem.ideal[1], label="Ideal")
plt.scatter(problem.nadir[0], problem.nadir[1], label="Nadir")
for i, z in enumerate(intermediate_request.content["objectives"]):
    plt.scatter(z[0], z[1], label=f"solution {i}")
plt.xlabel("f1")
plt.ylabel("f2")
plt.title("Approximate Pareto front of the Kursawe function")
plt.legend()
plt.show()
../_images/notebooks_synchronous_nimbus_37_0.png

Assume we really like what we have between solutions 3 and 4. Let NIMBUS compute 3 intermediate solutions between them:

[21]:
response = {
    "indices": [3, 4],
    "number_of_desired_solutions": 3,
    }
intermediate_request.response = response

save_request, plot_request = method.iterate(intermediate_request)

Plot the intermediate solutions:

[22]:
plt.scatter(p_front[:, 0], p_front[:, 1], label="Pareto front")
plt.scatter(problem.ideal[0], problem.ideal[1], label="Ideal")
plt.scatter(problem.nadir[0], problem.nadir[1], label="Nadir")
for i, z in enumerate(save_request.content["objectives"]):
    plt.scatter(z[0], z[1], label=f"solution {i}")
plt.xlabel("f1")
plt.ylabel("f2")
plt.title("Approximate Pareto front of the Kursawe function")
plt.legend()
plt.show()
../_images/notebooks_synchronous_nimbus_41_0.png

Nice, we are really getting there, even if we have no goal set… Let us save solution 1:

[23]:
response = {"indices": [1]}
save_request.response = response

intermediate_request, plot_request = method.iterate(save_request)

We do not wish to generate any more intermediate solutions.

[24]:
response = {"number_of_desired_solutions": 0, "indices": []}
intermediate_request.response = response

preferred_request, plot_request = method.iterate(intermediate_request)

Let us plot everything we have, and select a final solution:

[25]:
plt.scatter(p_front[:, 0], p_front[:, 1], label="Pareto front")
plt.scatter(problem.ideal[0], problem.ideal[1], label="Ideal")
plt.scatter(problem.nadir[0], problem.nadir[1], label="Nadir")
for i, z in enumerate(preferred_request.content["objectives"]):
    plt.scatter(z[0], z[1], label=f"solution {i}")
plt.xlabel("f1")
plt.ylabel("f2")
plt.title("Approximate Pareto front of the Kursawe function")
plt.legend()
plt.show()
../_images/notebooks_synchronous_nimbus_47_0.png

We REALLY like solution 6, so let us go with that:

[26]:
response = {
    "index": 6,
    "continue": False,
    }

preferred_request.response = response

stop_request, plot_request = method.iterate(preferred_request)

We are done, let us bask in the glory of the solution found:

[27]:
print(f"Final decision variables: {stop_request.content['solution']}")

plt.scatter(p_front[:, 0], p_front[:, 1], label="Pareto front")
plt.scatter(problem.ideal[0], problem.ideal[1], label="Ideal")
plt.scatter(problem.nadir[0], problem.nadir[1], label="Nadir")
plt.scatter(stop_request.content["objective"][0], stop_request.content["objective"][1], label=f"final solution")
plt.xlabel("f1")
plt.ylabel("f2")
plt.title("Approximate Pareto front of the Kursawe function")
plt.legend()
plt.show()
Final decision variables: [-1.02602425 -1.09914614 -1.09961167]
../_images/notebooks_synchronous_nimbus_51_1.png
[ ]: