Source code for desdeo_emo.utilities.ReferenceVectors

from itertools import combinations, product

import numpy as np
from pyDOE import lhs
from scipy.special import comb


[docs]def normalize(vectors): """ Normalize a set of vectors. The length of the returned vectors will be unity. Parameters ---------- vectors : np.ndarray Set of vectors of any length, except zero. """ if len(np.asarray(vectors).shape) == 1: return vectors / np.linalg.norm(vectors) norm = np.linalg.norm(vectors, axis=1) return vectors / norm[:, np.newaxis]
[docs]def shear(vectors, degrees: float = 5): """ Shear a set of vectors lying on the plane z=0 towards the z-axis, such that the resulting vectors 'degrees' angle away from the z axis. z is the last element of the vector, and has to be equal to zero. Parameters ---------- vectors : numpy.ndarray The final element of each vector should be zero. degrees : float, optional The angle that the resultant vectors make with the z axis. Unit is radians. (the default is 5) """ angle = degrees * np.pi / 180 m = 1 / np.tan(angle) norm = np.linalg.norm(vectors, axis=1) vectors[:, -1] += norm * m return normalize(vectors)
[docs]def rotate(initial_vector, rotated_vector, other_vectors): """Calculate the rotation matrix that rotates the initial_vector to the rotated_vector. Apply that rotation on other_vectors and return. Uses Householder reflections twice to achieve this.""" init_vec_norm = normalize(initial_vector) rot_vec_norm = normalize(np.asarray(rotated_vector)) middle_vec_norm = normalize(init_vec_norm + rot_vec_norm) first_reflector = init_vec_norm - middle_vec_norm second_reflector = middle_vec_norm - rot_vec_norm Q1 = householder(first_reflector) Q2 = householder(second_reflector) reflection_matrix = np.matmul(Q2, Q1) rotated_vectors = np.matmul(other_vectors, np.transpose(reflection_matrix)) return rotated_vectors
[docs]def householder(vector): """Return reflection matrix via householder transformation.""" identity_mat = np.eye(len(vector)) v = vector[np.newaxis] denominator = np.matmul(v, v.T) numerator = np.matmul(v.T, v) rot_mat = identity_mat - (2 * numerator / denominator) return rot_mat
[docs]def rotate_toward(initial_vector, final_vector, other_vectors, degrees: float = 5): """ Rotate other_vectors (with the centre at initial_vector) towards final_vector by an angle degrees. Parameters ---------- initial_vector : np.ndarray Centre of the vectors to be rotated. final_vector : np.ndarray The final position of the center of other_vectors. other_vectors : np.ndarray The array of vectors to be rotated degrees : float, optional The amount of rotation (the default is 5) Returns ------- rotated_vectors : np.ndarray The rotated vectors reached: bool True if final_vector has been reached """ final_vector = normalize(final_vector) initial_vector = normalize(initial_vector) cos_phi = np.dot(initial_vector, final_vector) theta = degrees * np.pi / 180 cos_theta = np.cos(theta) phi = np.arccos(cos_phi) if phi < theta: return (rotate(initial_vector, final_vector, other_vectors), True) cos_phi_theta = np.cos(phi - theta) A = np.asarray([[cos_phi, 1], [1, cos_phi]]) B = np.asarray([cos_phi_theta, cos_theta]) x = np.linalg.solve(A, B) rotated_vector = x[0] * initial_vector + x[1] * final_vector return (rotate(initial_vector, rotated_vector, other_vectors), False)
[docs]class ReferenceVectors: """Class object for reference vectors.""" def __init__( self, lattice_resolution: int = None, number_of_vectors: int = None, number_of_objectives: int = None, creation_type: str = "Uniform", vector_type: str = "Spherical", ref_point: list = None, ): """Create a Reference vectors object. A simplex lattice is formed Parameters ---------- lattice_resolution : int Number of divisions along an axis when creating the simplex lattice. number_of_objectives : int Number of objectives. creation_type : str, optional 'Uniform' creates the reference vectors uniformly using simplex lattice design. 'Focused' creates reference vectors symmetrically around a central reference vector. 'Reversed' coming soon.By default 'Uniform'. vector_type : str, optional 'Spherical' normalizes the vectors to a hypersphere, i.e. the second norm is equal to 1. 'Planar' normalizes vectors to a plane, i.e. the first norm is equal to 1. By default 'Spherical'. ref_point : list, optional User preference information for a priori methods. """ self.number_of_objectives = number_of_objectives self.lattice_resolution = lattice_resolution self.number_of_vectors = number_of_vectors if lattice_resolution is None: if number_of_vectors is None: raise ValueError( "Either lattice_resolution or number_of_vectors must be specified." ) temp_lattice_resolution = 0 while True: temp_lattice_resolution += 1 temp_number_of_vectors = comb( temp_lattice_resolution + self.number_of_objectives - 1, self.number_of_objectives - 1, exact=True, ) if temp_number_of_vectors > number_of_vectors: break self.lattice_resolution = temp_lattice_resolution - 1 self.creation_type = creation_type self.vector_type = vector_type self.values = [] self.values_planar = [] self.ref_point = [1] * number_of_objectives if ref_point is None else ref_point self._create(creation_type) self.initial_values = np.copy(self.values) self.initial_values_planar = np.copy(self.values_planar) self.neighbouring_angles() # self.iteractive_adapt_1() Can use this for a priori preferences!
[docs] def _create(self, creation_type: str = "Uniform"): """Create the reference vectors. Parameters ---------- creation_type : str, optional 'Uniform' creates the reference vectors uniformly using simplex lattice design. 'Focused' creates reference vectors symmetrically around a central reference vector. By default 'Uniform'. """ if creation_type == "Uniform": number_of_vectors = comb( self.lattice_resolution + self.number_of_objectives - 1, self.number_of_objectives - 1, exact=True, ) self.number_of_vectors = number_of_vectors temp1 = range(1, self.number_of_objectives + self.lattice_resolution) temp1 = np.array(list(combinations(temp1, self.number_of_objectives - 1))) temp2 = np.array( [range(self.number_of_objectives - 1)] * self.number_of_vectors ) temp = temp1 - temp2 - 1 weight = np.zeros( (self.number_of_vectors, self.number_of_objectives), dtype=int ) weight[:, 0] = temp[:, 0] for i in range(1, self.number_of_objectives - 1): weight[:, i] = temp[:, i] - temp[:, i - 1] weight[:, -1] = self.lattice_resolution - temp[:, -1] self.values = weight / self.lattice_resolution self.values_planar = np.copy(self.values) self.normalize() return elif creation_type == "Focused": point_set = [[0, 1, -1]] * (self.number_of_objectives - 1) # The cartesian product of point_set. initial = np.array(list(product(*point_set)))[1:] # First element was removed because of the error during normalization. initial = normalize(initial) initial = np.hstack((initial, np.zeros((initial.shape[0], 1)))) final = shear(initial, degrees=5) # Adding the first element back final = np.vstack(([0] * (self.number_of_objectives - 1) + [1], final)) self.number_of_vectors = final.shape[0] self.values = rotate(final[0], self.ref_point, final) self.values_planar = np.copy(self.values) self.normalize() self.add_edge_vectors() elif creation_type == "Sparse_Focused": initial = np.eye(self.number_of_objectives - 1) initial = np.vstack((initial, -initial)) initial = normalize(initial) initial = np.hstack((initial, np.zeros((initial.shape[0], 1)))) final = shear(initial, degrees=5) # Adding the first element back final = np.vstack(([0] * (self.number_of_objectives - 1) + [1], final)) self.number_of_vectors = final.shape[0] self.values = rotate(final[0], self.ref_point, final) self.values_planar = np.copy(self.values) self.normalize() self.add_edge_vectors()
[docs] def normalize(self): """Normalize the reference vectors to a unit hypersphere.""" self.number_of_vectors = self.values.shape[0] norm_2 = np.linalg.norm(self.values, axis=1).reshape(-1, 1) norm_1 = np.sum(self.values_planar, axis=1).reshape(-1, 1) norm_2[norm_2 == 0] = np.finfo(float).eps self.values = np.divide(self.values, norm_2) self.values_planar = np.divide(self.values_planar, norm_1)
[docs] def neighbouring_angles(self) -> np.ndarray: """Calculate neighbouring angles for normalization.""" cosvv = np.dot(self.values, self.values.transpose()) cosvv.sort(axis=1) cosvv = np.flip(cosvv, 1) cosvv[cosvv > 1] = 1 acosvv = np.arccos(cosvv[:, 1]) self.neighbouring_angles_current = acosvv return acosvv
[docs] def adapt(self, fitness: np.ndarray): """Adapt reference vectors. Then normalize. Parameters ---------- fitness : np.ndarray """ max_val = np.amax(fitness, axis=0) min_val = np.amin(fitness, axis=0) self.values = self.initial_values * (max_val - min_val) self.normalize()
[docs] def interactive_adapt_1( self, z: np.ndarray, n_solutions: int, translation_param: float = 0.2 ) -> None: """ Adapt reference vectors using the information about prefererred solution(s) selected by the Decision maker. Args: z (np.ndarray): Preferred solution(s). n_solutions (int): Number of solutions in total. translation_param (float): Parameter determining how close the reference vectors are to the central vector **v** defined by using the selected solution(s) z. Returns: """ if z.shape[0] == n_solutions: # if dm specifies all solutions as preferred, reinitialize reference vectors self.values = self.initial_values self.values_planar = self.initial_values_planar else: if z.shape[0] == 1: # single preferred solution # calculate new reference vectors self.values = translation_param * self.initial_values + ( (1 - translation_param) * z ) self.values_planar = translation_param * self.initial_values_planar + ( (1 - translation_param) * z ) else: # multiple preferred solutions # calculate new reference vectors for each preferred solution values = [ translation_param * self.initial_values + ((1 - translation_param) * z_i) for z_i in z ] values_planar = [ translation_param * self.initial_values_planar + ((1 - translation_param) * z_i) for z_i in z ] # combine arrays of reference vectors into a single array and update reference vectors self.values = np.concatenate(values) self.values_planar = np.concatenate(values_planar) self.normalize()
[docs] def interactive_adapt_2( self, z: np.ndarray, n_solutions: int, predefined_distance: float = 0.2 ) -> None: """ Adapt reference vectors by using the information about non-preferred solution(s) selected by the Decision maker. After the Decision maker has specified non-preferred solution(s), Euclidian distance between normalized solution vector(s) and each of the reference vectors are calculated. Those reference vectors that are **closer** than a predefined distance are either **removed** or **re-positioned** somewhere else. Note: At the moment, only the **removal** of reference vectors is supported. Repositioning of the reference vectors is **not** supported. Note: In case the Decision maker specifies multiple non-preferred solutions, the reference vector(s) for which the distance to **any** of the non-preferred solutions is less than predefined distance are removed. Note: Future developer should implement a way for a user to say: "Remove some percentage of objecive space/reference vectors" rather than giving a predefined distance value. Args: z (np.ndarray): Non-preferred solution(s). n_solutions (int): Number of solutions in total. predefined_distance (float): The reference vectors that are closer than this distance are either removed or re-positioned somewhere else. Default value: 0.2 Returns: """ if z.shape[0] == n_solutions: # if dm specifies all solutions as non-preferred ones, reinitialize reference vectors self.values = self.initial_values self.values_planar = self.initial_values_planar self.normalize() else: # calculate L1 norm of non-preferred solution(s) z = np.atleast_2d(z) norm = np.linalg.norm(z, ord=1, axis=1).reshape(np.shape(z)[0], 1) # non-preferred solutions normalized v_c = np.divide(z, norm) # distances from non-preferred solution(s) to each reference vector distances = np.array( [ list( map( lambda solution: np.linalg.norm(solution - value, ord=2), v_c, ) ) for value in self.values_planar ] ) # find out reference vectors that are not closer than threshold value to any non-preferred solution mask = [all(d >= predefined_distance) for d in distances] # set those reference vectors that met previous condition as new reference vectors, drop others self.values = self.values[mask] self.values_planar = self.values_planar[mask]
[docs] def iteractive_adapt_3(self, ref_point, translation_param=0.2): """Adapt reference vectors linearly towards a reference point. Then normalize. The details can be found in the following paper: Hakanen, Jussi & Chugh, Tinkle & Sindhya, Karthik & Jin, Yaochu & Miettinen, Kaisa. (2016). Connections of Reference Vectors and Different Types of Preference Information in Interactive Multiobjective Evolutionary Algorithms. Parameters ---------- ref_point : translation_param : (Default value = 0.2) """ self.values = self.initial_values * translation_param + ( (1 - translation_param) * ref_point ) self.values_planar = self.initial_values_planar * translation_param + ( (1 - translation_param) * ref_point ) self.normalize()
[docs] def interactive_adapt_4(self, preferred_ranges: np.ndarray) -> None: """ Adapt reference vectors by using the information about the Decision maker's preferred range for each of the objective. Using these ranges, Latin hypercube sampling is applied to generate m number of samples between within these ranges, where m is the number of reference vectors. Normalized vectors constructed of these samples are then set as new reference vectors. Args: preferred_ranges (np.ndarray): Preferred lower and upper bound for each of the objective function values. Returns: """ # bounds lower_limits = np.array([ranges[0] for ranges in preferred_ranges]) upper_limits = np.array([ranges[1] for ranges in preferred_ranges]) # generate samples using Latin hypercube sampling w = lhs(self.number_of_objectives, samples=self.number_of_vectors) # scale between bounds w = w * (upper_limits - lower_limits) + lower_limits # set new reference vectors and normalize them self.values = w self.values_planar = w self.normalize()
[docs] def slow_interactive_adapt(self, ref_point): """Basically a wrapper around rotate_toward. Slowly rotate ref vectors toward ref_point. Return a boolean value to tell if the ref_point has been reached. Parameters ---------- ref_point : list or np.ndarray The reference vectors will slowly move towards the ref_point. Returns ------- boolean True if ref_point has been reached. False otherwise. """ assert self.creation_type == "Focused" or self.creation_type == "Sparse_Focused" if np.array_equal(self.values[0], ref_point): return self.values, reached = rotate_toward( self.values[0], ref_point, self.values[0 : -self.number_of_objectives] ) self.values_planar = self.values self.add_edge_vectors() self.normalize() return reached
[docs] def add_edge_vectors(self): """Add edge vectors to the list of reference vectors. Used to cover the entire orthant when preference information is provided. """ edge_vectors = np.eye(self.values.shape[1]) self.values = np.vstack([self.values, edge_vectors]) self.values_planar = np.vstack([self.values_planar, edge_vectors]) self.number_of_vectors = self.values.shape[0] self.normalize()