"""PINSGA2 — interactive NSGA-II that guides selection with a value function learned from decision-maker preferences."""
import sys
from abc import ABC, abstractmethod
import numpy as np
from pymoo.algorithms.base.genetic import GeneticAlgorithm
from pymoo.algorithms.moo.nsga2 import binary_tournament
from pymoo.docs import parse_doc_string
from pymoo.operators.crossover.sbx import SBX
from pymoo.operators.mutation.pm import PM
from pymoo.operators.sampling.rnd import FloatRandomSampling
from pymoo.operators.selection.tournament import TournamentSelection
from pymoo.operators.survival.rank_and_crowding import RankAndCrowding
from pymoo.termination.default import DefaultMultiObjectiveTermination
from pymoo.util import value_functions as mvf
from pymoo.util.display.multi import MultiObjectiveOutput
from pymoo.util.nds.non_dominated_sorting import NonDominatedSorting
from pymoo.util.reference_direction import select_points_with_maximum_distance
from pymoo.util.vf_dominator import VFDominator
# =========================================================================================================
# Implementation
# =========================================================================================================
class AutomatedDM(ABC):
def __init__(self, get_pairwise_ranks_func=None):
self.get_pairwise_ranks_func = get_pairwise_ranks_func
@abstractmethod
def makeDecision(self, F):
pass
def makePairwiseDecision(self, F):
def dm(F):
return self.makeDecision(F)
ranks = self.get_pairwise_ranks_func(F, 1, dm=dm)
return ranks
[docs]
class PINSGA2(GeneticAlgorithm):
def __init__(
self,
pop_size=100,
sampling=FloatRandomSampling(),
selection=TournamentSelection(func_comp=binary_tournament),
crossover=SBX(eta=15, prob=0.9),
mutation=PM(eta=20),
output=MultiObjectiveOutput(),
tau=10,
eta=4,
opt_method="trust-constr",
vf_type="poly",
eps_max=1000,
vf_max_iter=400,
ranking_type="pairwise",
presi_signs=None,
automated_dm=None,
verbose=False,
**kwargs,
):
self.survival = RankAndCrowding(
nds=NonDominatedSorting(dominator=VFDominator(self))
)
super().__init__(
pop_size=pop_size,
sampling=sampling,
selection=selection,
crossover=crossover,
mutation=mutation,
survival=self.survival,
output=output,
advance_after_initial_infill=True,
**kwargs,
)
self.termination = DefaultMultiObjectiveTermination()
self.tournament_type = "comp_by_dom_and_crowding"
self.ranking_type = ranking_type
self.presi_signs = presi_signs
self.vf_type = vf_type
self.opt_method = opt_method
self.tau = tau
self.eta = eta
self.eta_F = []
self.vf_res = None
self.v2 = None
self.vf_plot_flag = False
self.vf_plot = None
self.historical_F = None
self.prev_pop = None
self.fronts = []
self.eps_max = eps_max
# Cap the scipy VF fit; infeasible instances otherwise grind to scipy's
# maxiter=1000 and are discarded anyway. Set None to restore the full budget.
self.vf_max_iter = vf_max_iter
self.verbose = verbose
if automated_dm is not None:
automated_dm.get_pairwise_ranks_func = self._get_pairwise_ranks
self.automated_dm = automated_dm
def _warn(self, msg):
if self.verbose:
sys.stderr.write(msg)
@staticmethod
def _prompt_for_ranks(F, presi_signs):
for e, f in enumerate(F):
print("Solution %d %s" % (e + 1, f * presi_signs))
dim = F.shape[0]
raw_ranks = input(
f'Ranks (e.g., "3, {dim}, ..., 1" for 3rd best, {dim}th best, ..., 1st best): '
)
if raw_ranks == "":
ranks = []
else:
ranks = [int(raw_rank) for raw_rank in raw_ranks.split()]
return ranks
@staticmethod
def _present_ranks(F, dm_ranks, presi_signs):
print("Solutions are ranked as:")
for e, f in enumerate(F):
print("Solution %d %s: Rank %d" % (e + 1, f * presi_signs, dm_ranks[e]))
@staticmethod
def _get_pairwise_ranks(F, presi_signs, dm=None):
if not dm:
def dm(F):
return input(
"\nWhich solution do you like best?\n"
f"[a] {F[0]}\n"
f"[b] {F[1]}\n"
"[c] These solutions are equivalent.\n--> "
)
# initialize empty ranking
_ranks = []
for i, f in enumerate(F):
# handle empty case, put first element in first place
if not _ranks:
_ranks.append([i])
else:
inserted = False
# for each remaining elements, compare to all currently ranked elements
for j, group in enumerate(_ranks):
# get pairwise preference from user
while True:
points_to_compare = np.array(
[f * presi_signs, F[group[0]] * presi_signs]
)
preference_raw = dm(points_to_compare)
preference = preference_raw.strip().lower()
if preference in ["a", "b", "c"]:
break
print("Invalid input. Please enter 'a', 'b', or 'c'.")
# if better than currently ranked element place before that element
if preference == "a":
_ranks.insert(j, [i])
inserted = True
break
# if equal to currently ranked element place with that element
elif preference == "c":
group.append(i)
inserted = True
break
# if found to be worse than all place at the end
if not inserted:
_ranks.append([i])
ranks = np.zeros(len(F), dtype=int)
for rank, group in enumerate(_ranks):
for index in group:
ranks[index] = rank
return np.array(ranks) + 1
@staticmethod
def _get_ranks(F, presi_signs):
ranks_invalid = True
dim = F.shape[0]
print(
f"Give each solution a ranking, with 1 being the highest score, and {dim} being the lowest score:"
)
ranks = PINSGA2._prompt_for_ranks(F, presi_signs)
while ranks_invalid:
fc = F.shape[0]
if len(ranks) > 0 and max(ranks) <= fc and min(ranks) >= 1:
ranks_invalid = False
else:
print("Invalid ranks given. Please try again")
ranks = PINSGA2._prompt_for_ranks(F, presi_signs)
return np.array(ranks)
def _reset_dm_preference(self):
self._warn("Back-tracking and removing DM preference from search.")
self.eta_F = []
self.vf_res = None
self.v2 = None
self.vf_plot_flag = False
self.vf_plot = None
self.pop = self.prev_pop
def _advance(self, infills=None, **kwargs):
super()._advance(infills=infills, **kwargs)
rank, F = self.pop.get("rank", "F")
self.fronts = rank
F = F[rank == 0]
if self.historical_F is not None:
self.historical_F = np.vstack((self.historical_F, F))
else:
self.historical_F = F
to_find = self.eta if F.shape[0] >= self.eta else F.shape[0]
if self.presi_signs is None:
self.presi_signs = np.ones(F.shape[1])
# Eta is the number of solutions displayed to the DM
eta_F_indices = select_points_with_maximum_distance(
F, to_find, random_state=self.random_state
)
self.eta_F = F[eta_F_indices]
self.eta_F = self.eta_F[self.eta_F[:, 0].argsort()]
# Remove duplicate rows
self.eta_F = np.unique(self.eta_F, axis=0)
# A frozen view of the optimization each tau generations
self.paused_F = F
# Record the previous population in case we need to back track
self.prev_pop = self.pop
dm_time = self.n_gen % self.tau == 0
# Check whether we have more than one solution
if dm_time and len(self.eta_F) < 2:
self._warn("Population only contains one non-dominated solution. ")
self._reset_dm_preference()
elif dm_time:
# Check if the DM is a machine or a human
if self.automated_dm is None:
# Human DM
if self.ranking_type == "absolute":
dm_ranks = PINSGA2._get_ranks(self.eta_F, self.presi_signs)
elif self.ranking_type == "pairwise":
dm_ranks = PINSGA2._get_pairwise_ranks(self.eta_F, self.presi_signs)
PINSGA2._present_ranks(self.eta_F, dm_ranks, self.presi_signs)
else:
raise ValueError(
"Invalid ranking type [%s] given." % self.ranking_type
)
else:
# Automated DM
if self.ranking_type == "absolute":
dm_ranks = self.automated_dm.makeDecision(self.eta_F)
elif self.ranking_type == "pairwise":
dm_ranks = self.automated_dm.makePairwiseDecision(self.eta_F)
else:
raise ValueError(
"Invalid ranking type [%s] given." % self.ranking_type
)
if len(set(rank)) == 0:
self._warn("No preference between any two points provided.")
self._reset_dm_preference()
return
eta_F = self.eta_F
while eta_F.shape[0] > 1:
if self.vf_type == "linear":
vf_res = mvf.create_linear_vf(
eta_F * -1,
dm_ranks.tolist(),
eps_max=self.eps_max,
method=self.opt_method,
max_iter=self.vf_max_iter,
)
elif self.vf_type == "poly":
vf_res = mvf.create_poly_vf(
eta_F * -1,
dm_ranks.tolist(),
eps_max=self.eps_max,
method=self.opt_method,
max_iter=self.vf_max_iter,
)
else:
raise ValueError("Value function %s not supported" % self.vf_type)
# check if we were able to model the VF
if vf_res.fit:
self.vf_res = vf_res
self.vf_plot_flag = True
self.v2 = self.vf_res.vf(eta_F[dm_ranks[1] - 1] * -1).item()
break
else:
# If we didn't the model, try to remove the least preferred point and try to refit
self._warn("Could not fit a function to the DM preference")
if eta_F.shape[0] == 2:
# If not, reset and use normal domination
self._warn("Removing DM preference")
self._reset_dm_preference()
break
else:
self._warn(
"Removing the second best preferred solution from the fit."
)
# ranks start at 1, not zero
rank_to_remove = dm_ranks[1]
eta_F = np.delete(eta_F, rank_to_remove - 1, axis=0)
dm_ranks = np.concatenate(([dm_ranks[0]], dm_ranks[2:]))
# update the ranks, since we just removed one
dm_ranks[dm_ranks > rank_to_remove] = (
dm_ranks[dm_ranks > rank_to_remove] - 1
)
parse_doc_string(PINSGA2.__init__)