"""Omni-Optimizer: a generic NSGA-II based algorithm for single/multi-objective and single/multi-modal optimization (Deb & Tiwari, 2008)."""
import numpy as np
from pymoo.algorithms.base.genetic import GeneticAlgorithm
from pymoo.algorithms.moo.nsga2 import binary_tournament
from pymoo.core.selection import Selection
from pymoo.core.survival import Survival
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.lhs import LHS
from pymoo.termination.default import DefaultMultiObjectiveTermination
from pymoo.util.display.multi import MultiObjectiveOutput
from pymoo.util.misc import has_feasible
from pymoo.util.nds.non_dominated_sorting import NonDominatedSorting
from pymoo.util.randomized_argsort import randomized_argsort
# A finite value (larger than any normalized crowding distance) assigned to boundary
# solutions. The original implementation deliberately uses a finite sentinel instead of
# infinity so that the average crowding distance used to combine the objective- and
# variable-space metrics remains well defined.
BOUNDARY = 10.0
# =========================================================================================================
# Epsilon (loose) dominance with a dynamically calculated epsilon
# =========================================================================================================
class LooseDominator:
"""Modified (loose) epsilon-dominance used by the Omni-Optimizer [1]_.
A solution ``a`` is said to loosely dominate ``b`` only if it dominates ``b`` in the
usual Pareto sense *and* is better by more than a margin ``delta * epsilon_j`` in at
least one objective ``j``. The per-objective epsilon is calculated dynamically from
the population that is being sorted as the range of each objective::
epsilon_j = max_j(F) - min_j(F)
Solutions that are closer than ``delta * epsilon_j`` in every objective are therefore
treated as mutually non-dominated and end up in the same front. Together with the
variable-space crowding distance this is what allows the Omni-Optimizer to maintain
multiple equivalent (in objective space) solutions.
This class follows the ``calc_domination_matrix`` interface so it can be plugged into
:class:`~pymoo.util.nds.non_dominated_sorting.NonDominatedSorting` via its
``dominator`` argument.
Parameters
----------
delta : float
Fraction of the per-objective range used as the epsilon margin. Defaults to 0.001.
References:
----------
.. [1] K. Deb and S. Tiwari, "Omni-optimizer: A generic evolutionary algorithm for
single and multi-objective optimization", European Journal of Operational Research,
185(3), 2008, pp. 1062-1087.
"""
def __init__(self, delta=0.001):
self.delta = delta
def calc_domination_matrix(self, F, _F=None):
if _F is None:
_F = F
n, m = F.shape[0], _F.shape[0]
# epsilon is calculated dynamically as a fraction of the range of each objective
epsilon = self.delta * (F.max(axis=0) - F.min(axis=0))
# build all pairwise combinations (i-th block compares F[i] against every _F)
L = np.repeat(F, m, axis=0)
R = np.tile(_F, (n, 1))
# usual Pareto relation: is the left solution better / worse in any objective?
better = np.any(L < R, axis=1).reshape(n, m)
worse = np.any(L > R, axis=1).reshape(n, m)
# the left solution dominates / is dominated in the usual sense
dominates = better & ~worse
dominated = worse & ~better
# the relation only counts if the margin is exceeded in at least one objective
better_by_eps = np.any(L + epsilon < R, axis=1).reshape(n, m)
worse_by_eps = np.any(L > R + epsilon, axis=1).reshape(n, m)
M = (dominates & better_by_eps).astype(int) - (dominated & worse_by_eps).astype(
int
)
return M
# =========================================================================================================
# Crowding distance in objective and variable space
# =========================================================================================================
def calc_crowding_distance_in_space(Y, space="objective"):
"""Crowding distance of a single front computed in one space.
This is the NSGA-II crowding distance (sum of the normalized distances to the nearest
neighbors along each dimension), with two characteristics of the Omni-Optimizer [1]_:
- the contribution is averaged over the number of dimensions, and
- boundary solutions are handled differently in objective and variable space.
In objective space the extreme solutions of each objective receive the (finite)
:data:`BOUNDARY` value so that the best solution of every objective is preserved. In
variable space no solution is treated as infinitely important; instead the boundary
solutions receive twice the distance to their only neighbor, mirroring the reference
implementation.
Parameters
----------
Y : numpy.ndarray
``(n, d)`` matrix of either objective values or decision variables of the front.
space : str
Either ``"objective"`` or ``"variable"``.
References:
----------
.. [1] K. Deb and S. Tiwari, "Omni-optimizer: A generic evolutionary algorithm for
single and multi-objective optimization", European Journal of Operational Research,
185(3), 2008, pp. 1062-1087.
"""
n, d = Y.shape
# for one or two solutions every solution is a boundary solution
if n <= 2:
return np.full(n, BOUNDARY)
cd = np.zeros(n)
is_boundary = np.zeros(n, dtype=bool)
for j in range(d):
order = np.argsort(Y[:, j], kind="mergesort")
lo, hi = order[0], order[-1]
span = Y[hi, j] - Y[lo, j]
if space == "objective":
# the best (minimum) solution of this objective is a boundary solution
is_boundary[lo] = True
if span != 0:
interior = order[1:-1]
cd[interior] += (Y[order[2:], j] - Y[order[:-2], j]) / span
else:
if span != 0:
# the boundary solutions get twice the gap to their single neighbor
cd[lo] += 2.0 * (Y[order[1], j] - Y[lo, j]) / span
cd[hi] += 2.0 * (Y[hi, j] - Y[order[-2], j]) / span
interior = order[1:-1]
cd[interior] += (Y[order[2:], j] - Y[order[:-2], j]) / span
cd /= d
if space == "objective":
cd[is_boundary] = BOUNDARY
return cd
def calc_omni_crowding_distance(F, X, obj_crowding=True, var_crowding=True):
"""Combined objective- and variable-space crowding distance of a single front.
The crowding distance is computed independently in objective and variable space
(see :func:`calc_crowding_distance_in_space`). For every solution, if it is less
crowded than the average of the front in *either* space the larger of the two values
is assigned, otherwise the smaller one is used. This rewards solutions that maintain
diversity in at least one of the two spaces.
Parameters
----------
F : numpy.ndarray
Objective values of the front, ``(n, n_obj)``.
X : numpy.ndarray
Decision variables of the front, ``(n, n_var)``.
obj_crowding, var_crowding : bool
Whether to use the objective- and/or variable-space niching. At least one of them
must be enabled. Disabling the variable-space niching recovers the NSGA-II
behavior; disabling the objective-space niching niches purely in variable space.
"""
if not (obj_crowding or var_crowding):
raise ValueError(
"At least one of objective- or variable-space crowding must be enabled."
)
n = len(F)
obj_cd = (
calc_crowding_distance_in_space(F, space="objective") if obj_crowding else None
)
var_cd = (
calc_crowding_distance_in_space(X, space="variable") if var_crowding else None
)
# only a single space is used
if not var_crowding:
return obj_cd
if not obj_crowding:
return var_cd
n_obj, n_var = F.shape[1], X.shape[1]
# the average crowding distance of the front excluding boundary solutions in obj. space
avg_obj = obj_cd[obj_cd != BOUNDARY].sum() / n / n_obj
avg_var = var_cd.sum() / n / n_var
take_max = (obj_cd > avg_obj) | (var_cd > avg_var)
cd = np.where(take_max, np.maximum(obj_cd, var_cd), np.minimum(obj_cd, var_cd))
return cd
# =========================================================================================================
# Survival
# =========================================================================================================
class OmniOptimizerSurvival(Survival):
"""Rank and (objective + variable space) crowding survival of the Omni-Optimizer [1]_.
The non-dominated sorting uses the dynamically calculated epsilon (loose) dominance
(:class:`LooseDominator`) and the last surviving front is truncated by the combined
objective- and variable-space crowding distance (:func:`calc_omni_crowding_distance`).
Parameters
----------
delta : float
Epsilon margin (fraction of each objective's range) for the loose dominance.
obj_crowding, var_crowding : bool
Whether to niche in objective and/or variable space.
References:
----------
.. [1] K. Deb and S. Tiwari, "Omni-optimizer: A generic evolutionary algorithm for
single and multi-objective optimization", European Journal of Operational Research,
185(3), 2008, pp. 1062-1087.
"""
def __init__(self, delta=0.001, obj_crowding=True, var_crowding=True):
super().__init__(filter_infeasible=True)
self.delta = delta
self.obj_crowding = obj_crowding
self.var_crowding = var_crowding
self.nds = NonDominatedSorting(dominator=LooseDominator(delta=delta))
def _do(self, problem, pop, *args, n_survive=None, random_state=None, **kwargs):
# objective values and decision variables of the (feasible) population
F = pop.get("F").astype(float, copy=False)
X = pop.get("X").astype(float, copy=False)
survivors = []
# non-dominated sorting using the dynamic epsilon (loose) dominance
fronts = self.nds.do(F, n_stop_if_ranked=n_survive)
for k, front in enumerate(fronts):
# combined objective- and variable-space crowding distance of the front
crowding_of_front = calc_omni_crowding_distance(
F[front, :],
X[front, :],
obj_crowding=self.obj_crowding,
var_crowding=self.var_crowding,
)
# save rank and crowding in the individual class
for j, i in enumerate(front):
pop[i].set("rank", k)
pop[i].set("crowding", crowding_of_front[j])
# current front sorted by crowding distance if splitting
if len(survivors) + len(front) > n_survive:
idx = randomized_argsort(
crowding_of_front,
order="descending",
method="numpy",
random_state=random_state,
)
idx = idx[: (n_survive - len(survivors))]
# otherwise take the whole front
else:
idx = np.arange(len(front))
survivors.extend(front[idx])
return pop[survivors]
# =========================================================================================================
# Restricted (nearest neighbor) mating selection
# =========================================================================================================
class NeighborBasedTournamentSelection(Selection):
"""Restricted binary tournament selection of the Omni-Optimizer [1]_.
Instead of pairing two random solutions, each tournament is held between a randomly
drawn solution and its nearest neighbor in the (normalized) decision space. The two
competitors are removed from the pool, so that every solution participates in exactly
one tournament per pass over the population. The comparison itself is the usual
NSGA-II crowded-comparison (Pareto dominance, then crowding distance, then random).
Restricting the mating to nearby solutions biases recombination towards the same
region of the decision space, which helps to preserve distinct (but equivalent)
optima.
Parameters
----------
func_comp : callable
The binary tournament comparison. Defaults to NSGA-II's ``binary_tournament``.
References:
----------
.. [1] K. Deb and S. Tiwari, "Omni-optimizer: A generic evolutionary algorithm for
single and multi-objective optimization", European Journal of Operational Research,
185(3), 2008, pp. 1062-1087.
"""
def __init__(self, func_comp=binary_tournament, **kwargs):
super().__init__(**kwargs)
self.func_comp = func_comp
def _do(self, problem, pop, n_select, n_parents, random_state=None, **kwargs):
n_winners = n_select * n_parents
n = len(pop)
# normalize the decision space so that every variable contributes equally to the
# distance used to determine the nearest neighbor
X = pop.get("X").astype(float, copy=False)
xl, xu = X.min(axis=0), X.max(axis=0)
norm = xu - xl
norm[norm == 0] = 1.0
Xn = (X - xl) / norm
# collect (solution, nearest neighbor) pairs to compete against each other
pairs = np.empty((n_winners, 2), dtype=int)
count = 0
while count < n_winners:
# a fresh random order of all solutions for this pass
remaining = list(random_state.permutation(n))
while len(remaining) >= 2 and count < n_winners:
# the first (randomly drawn) solution of the pass
p = remaining.pop(0)
# its nearest neighbor in normalized decision space among the remaining
rest = np.array(remaining)
dist = np.sum((Xn[rest] - Xn[p]) ** 2, axis=1)
nn = int(np.argmin(dist))
q = remaining.pop(nn)
pairs[count] = (p, q)
count += 1
# run the binary tournaments
S = self.func_comp(pop, pairs, random_state=random_state, **kwargs)
return np.reshape(S, (n_select, n_parents))
# =========================================================================================================
# Algorithm
# =========================================================================================================
[docs]
class OmniOptimizer(GeneticAlgorithm):
def __init__(
self,
pop_size=100,
delta=0.001,
obj_crowding=True,
var_crowding=True,
sampling=LHS(),
selection=NeighborBasedTournamentSelection(func_comp=binary_tournament),
crossover=SBX(eta=20, prob=0.8),
mutation=PM(eta=20),
survival=None,
output=MultiObjectiveOutput(),
**kwargs,
):
"""Omni-Optimizer: a generic evolutionary algorithm for single/multi-objective optimization.
Proposed by Deb and Tiwari (*European Journal of Operational Research*, 185(3),
2008, pp. 1062-1087) for single- and multi-objective, single- and multi-global
optimization. It is an NSGA-II based algorithm with three distinctive components:
- a non-dominated sorting based on a *loose* epsilon-dominance whose epsilon is
calculated dynamically from the population (:class:`LooseDominator`),
- a crowding distance computed in *both* the objective and the variable space
(:func:`calc_omni_crowding_distance`), and
- a restricted binary tournament selection between a solution and its nearest
neighbor in the decision space (:class:`NeighborBasedTournamentSelection`).
These components allow the algorithm to find and maintain multiple equivalent
Pareto-optimal solutions, i.e. solutions that map to (almost) the same point in
objective space but are distinct in decision space.
Args:
pop_size: The population size.
delta: The epsilon margin (as a fraction of each objective's range) used for
the loose dominance. ``delta=0`` recovers the usual Pareto dominance.
obj_crowding: Whether to niche in the objective space.
var_crowding: Whether to niche in the variable space. Disabling it essentially
recovers NSGA-II.
sampling: Sampling operator (defaults to the operator from the original paper).
selection: Selection operator (defaults to the operator from the original paper).
crossover: Crossover operator (defaults to SBX, as in the original paper).
mutation: Mutation operator (defaults to polynomial mutation, as in the paper).
survival: Survival operator (defaults to :class:`OmniOptimizerSurvival`).
output: Display output used during the optimization run.
**kwargs: Additional keyword arguments passed to the genetic algorithm base class.
"""
if survival is None:
survival = OmniOptimizerSurvival(
delta=delta, obj_crowding=obj_crowding, var_crowding=var_crowding
)
super().__init__(
pop_size=pop_size,
sampling=sampling,
selection=selection,
crossover=crossover,
mutation=mutation,
survival=survival,
output=output,
advance_after_initial_infill=True,
**kwargs,
)
self.termination = DefaultMultiObjectiveTermination()
self.tournament_type = "comp_by_dom_and_crowding"
def _set_optimum(self, **kwargs):
if not has_feasible(self.pop):
self.opt = self.pop[[np.argmin(self.pop.get("CV"))]]
else:
self.opt = self.pop[self.pop.get("rank") == 0]
parse_doc_string(OmniOptimizer.__init__)