diff --git a/.gitignore b/.gitignore index 180218d4e..0344fed1a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,6 @@ MANIFEST *.pyc *.pyo *.orig +*~ .DS_Store *.so diff --git a/deap/_algorithms/__init__.py b/deap/_algorithms/__init__.py new file mode 100644 index 000000000..3cabb7672 --- /dev/null +++ b/deap/_algorithms/__init__.py @@ -0,0 +1,16 @@ +# This file is part of DEAP. +# +# DEAP is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# DEAP is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with DEAP. If not, see . + +from .moead import MOEAD diff --git a/deap/_algorithms/moead.py b/deap/_algorithms/moead.py new file mode 100644 index 000000000..45d92a53a --- /dev/null +++ b/deap/_algorithms/moead.py @@ -0,0 +1,353 @@ +# This file is part of DEAP. +# +# DEAP is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# DEAP is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with DEAP. If not, see . + +import random +import operator + +import numpy as np + +from deap import tools + +def sort_index(array): + """Sort array index in assending order. + + :params array: An array to be sorted. + :returns: An index array sorted in assending order for given array. + """ + index = [i for i in range(len(array))] + for i in range(1, len(array)): + for j in range(i): + if array[index[i]] < array[index[j]]: + temp = index[i] + for k in range(i-1, j-1, -1): + index[k+1] = index[k] + index[j] = temp + break + return index + +class MOEAD: + """Implementation of MOEA/D. + """ + def __init__(self, population, toolbox, weights, neighbour_size, + scalar_method, cxpb, mutpb, ngen, stats, verbose): + self.population = population + self.toolbox = toolbox + self.weights = np.array(weights) + self.neighbour_size = neighbour_size + self.scalar_method = scalar_method + self.cxpb = cxpb + self.mutpb = mutpb + self.ngen = ngen + self.stats = stats + self.verbose = verbose + + if hasattr(toolbox, 'improve'): + self.improve = self.toolbox.improve + else: + self.improve = self.noop_improve + + if self.scalar_method == 'weightedSum': + self.scalarize_objective = self.weighted_sum + elif self.scalar_method == 'tchebycheff': + self.scalarize_objective = self.tchebycheff + else: + raise ValueError('unknown scalar method') + + def run(self): + """Execute MOEA/D. + + :returns: The external population + :returns: A class:`~deap.tools.Logbook` with the statistics of the + evolution + """ + self.logbook = tools.Logbook() + logbook_header = ['gen', 'nevals'] + logbook_header += (self.stats.fields if self.stats else []) + self.logbook.header = logbook_header + + # Step 1) + self.initialize() + npop = len(self.population) + + record = self.stats.compile(self.population) if self.stats else {} + self.logbook.record(gen=0, nevals=npop, **record) + if self.verbose: + print(self.logbook.stream) + + # Step 2) + for i in range(self.ngen): + for j in range(npop): + self.update(j) + + record = self.stats.compile(self.population) if self.stats else {} + self.logbook.record(gen=i+1, nevals=npop, **record) + if self.verbose: + print(self.logbook.stream) + + return self.EP, self.logbook + + def initialize(self): + """Step 1) Initialization. + """ + + # Step 1.1) + self.EP = [] + + # Step 1.2) + self.initialize_neighbour() + + # Step 1.3) + # Generate an initial population at this step in the paper. + # But this implementation assumes population as argument. + + # Step 1.4) + self.ideal_point = [] + for w in self.population[0].fitness.weights: + if w > 0: + # Initialize very small value for maximize + self.ideal_point.append(-1.0e+30) + else: + # Initialize very large value for minimize + self.ideal_point.append(1.0e+30) + + for ind in self.population: + ind.fitness.values = self.toolbox.evaluate(ind) + self.update_reference(ind) + + def initialize_neighbour(self): + """Step 1.2) Compute Euclidean distances between any two weight vectors + and then work out the T closest weight vectors to each weight vector. + """ + self.neighbour_table = [] + npop = len(self.population) + distance_matrix = np.zeros((npop, npop)) + for i in range(npop): + for j in range(i+1, npop): + distance = np.linalg.norm(self.weights[i] - self.weights[j]) + distance_matrix[j, i] = distance_matrix[i, j] = distance + + for i in range(npop): + index = sort_index(distance_matrix[i]) + self.neighbour_table.append(index[:self.neighbour_size]) + + def update(self, i): + """Step 2) Update. + + :params i: An index of subproblem. + """ + offspring = self.generate_offspring(i) + offspring = self.improve(i, offspring) + offspring.fitness.values = self.toolbox.evaluate(offspring) + self.update_reference(offspring) + self.update_neighbours(i, offspring) + self.update_EP(offspring) + + def generate_offspring(self, i): + """Step 2.1) Reproduction. + + :params i: An index of subproblem. + """ + neighbour_table = self.neighbour_table[i][:] + # Select neighbour but not i + neighbour_table.remove(i) + k = random.choice(neighbour_table) + # Select neighbour but not i, k + neighbour_table.remove(k) + l = random.choice(neighbour_table) + + c1 = self.population[k] + c2 = self.population[l] + + choice = random.random() + if choice < self.cxpb: + c1 = self.toolbox.clone(c1) + c2 = self.toolbox.clone(c2) + c1, c2 = self.toolbox.mate(c1, c2) + del c1.fitness.values + choice = random.random() + if choice < self.mutpb: + c1 = self.toolbox.clone(c1) + c1, = self.toolbox.mutate(c1) + del c1.fitness.values + # Ignore one individual. Bad idea? + offspring = c1 + + return offspring + + def noop_improve(self, i, offspring): + """Step 2.2) Improvement. + No improvement default. Register :meth:`toolbox.improve` for problem + specific improvement. + + :params i: An index of subproblem. + :params offspring: individual to improve. + :returns: improved individual. + """ + return offspring + + def update_reference(self, ind): + """Step 2.3) Uodate of z. + Also use by Step 1.4) initialize z. + + :params ind: An individual that maybe update ideal point. + """ + for i in range(len(ind.fitness.values)): + if ind.fitness.weights[i] > 0: + if ind.fitness.values[i] > self.ideal_point[i]: + self.ideal_point[i] = ind.fitness.values[i] + else: + if ind.fitness.values[i] < self.ideal_point[i]: + self.ideal_point[i] = ind.fitness.values[i] + + def update_neighbours(self, i, offspring): + """Step 2.4) Update of Neighbouring solutions. + + :params i: An index of subproblem. + :params offspring: An individual that maybe update neighbour. + """ + for j in range(self.neighbour_size): + weight_index = self.neighbour_table[i][j] + sol = self.population[weight_index] + d = self.calculate_scalar(weight_index, offspring) + e = self.calculate_scalar(weight_index, sol) + if d < e: + self.population[weight_index] = self.toolbox.clone(offspring) + + def calculate_scalar(self, weight_index, ind): + """Calculate scalar value for individual. + + :params weight_index: An index of weight vector. + :params ind: An indivudlal to calculate scalar value. + :returns: Scalalized fitness value. + """ + weight = self.weights[weight_index] + return self.scalarize_objective(weight, ind) + + def weighted_sum(self, weight, ind): + """Calculate scalar value using Weighted Sum Approch. + + :params weight: A weight vector. + :params ind: An indivudlal to calculate scalar value. + :returns: Scalalized fitness value. + """ + return np.sum(weight * ind.fitness.values) + + def tchebycheff(self, weight, ind): + """Calculate scalar value using Tchebycheff Approch. + + :params weight: A weight vector. + :params ind: An indivudlal to calculate scalar value. + :returns: Scalalized fitness value. + """ + max_fun = -1.0e+30 + for f, z, w in zip(ind.fitness.values, self.ideal_point, weight): + diff = abs(f - z) + if w == 0: + feval = 0.00001 * diff + else: + feval = w * diff + if feval > max_fun: + max_fun = feval + return max_fun + + def update_EP(self, offspring): + """Step 2.5) Update of EP. + + :params offspring: An individual that maybe add to EP. + """ + + """ + # Setup condition list for each objective + cond = [operator.gt if w > 0 else operator.lt + for w in offspring.fitness.weights] + + nobj = len(cond) + def dominated(a, b): + # Dominated means all conditions are True + return sum([ + c(a, b) + for a, b, c in zip(a.fitness.values, b.fitness.values, cond) + ]) == nobj + + # Remove from EP if all vectors dominated by offspring + self.EP = [ind for ind in self.EP if not dominated(offspring, ind)] + + # Add offspring to EP if no vectors in EP dominate offspring + for ind in self.EP: + if offspring == ind: + return + if dominated(ind, offspring): + return + self.EP.append(offspring) + """ + + # Above code is clear but very slow. + # So, speed up using numpy. + + # np.array for empty list makes bad shape. + # So, first check and return. + if not self.EP: + self.EP.append(offspring) + return + + # Setup condition list for each objective(like above but numpy) + cond = [np.greater if w > 0 else np.less + for w in offspring.fitness.weights] + + nobj = len(offspring.fitness.weights) + + # Make fitness array for EP and offspring + epf = np.array([ind.fitness.values for ind in self.EP]) + osf = np.array(offspring.fitness.values) + + # Evaluate condition for each objective + check = np.empty(epf.shape, dtype=np.bool) + for i, c in enumerate(cond): + check[:, i] = c(osf[i], epf[:, i]) + # Dominated means all conditions are True + dominated = (check.sum(axis=1) == nobj) + + # Remove from EP if all vectors dominated by offspring + self.EP = [ind for ind, dom in zip(self.EP, dominated) if not dom] + + # If all EP are removed, simple add offspring. + if not self.EP: + self.EP.append(offspring) + return + + # Don't add same gene. + # This is useful for binary code, but slow down for real gene(exact + # same gene is very rare). + # So, enable only not float gene. + if not type(offspring[0]) == float: + epg = np.array(self.EP) + osg = np.array(offspring) + # Count same gene and check that count is equal to length of gene. + same = ((epg == osg).sum(axis=1) == len(offspring)) + # Same gene is already in EP. Don't add. + if np.any(same): + return + + # Remove dominated idnividual from EP fitness array + epf = epf[~dominated] + + # Like above dominate check but reverse operand + check = np.empty(epf.shape, dtype=np.bool) + for i, c in enumerate(cond): + check[:, i] = c(epf[:, i], osf[i]) + dominated = check.sum(axis=1) == nobj + # Add offspring to EP if no vectors in EP dominate offspring + if dominated.sum() == 0: + self.EP.append(offspring) diff --git a/deap/algorithms.py b/deap/algorithms.py index b08897209..c97ca9266 100644 --- a/deap/algorithms.py +++ b/deap/algorithms.py @@ -28,6 +28,7 @@ import random import tools +import _algorithms def varAnd(population, toolbox, cxpb, mutpb): @@ -501,3 +502,45 @@ def eaGenerateUpdate(toolbox, ngen, halloffame=None, stats=None, print logbook.stream return population, logbook + + +def moead(population, toolbox, weights, neighbour_size, scalar_method, cxpb, + mutpb, ngen, stats=None, verbose=__debug__): + """This is algorithm implements the MOEA/D propsed in [Zhang2007]_. + + :param population: A list of individuals. + :param toolbox: A :class:`~deap.base.Toolbox` that contains the evolution + operators. + :param weights: A list of weight vectors. + :param neighbour_size: The number of neighbour. + :param scalar_method: The method to scalarize multi objective. + Specify 'weightedSum' or 'tchebycheff'. + :param cxpb: The probability of mating two individuals. + :param mutpb: The probability of mutating an individual. + :param ngen: The number of generation. + :param stats: A :class:`~deap.tools.Statistics` object that is updated + inplace, optional. + :param verbose: Whether or not to log the statistics. + :returns: The external population + :returns: A class:`~deap.tools.Logbook` with the statistics of the + evolution + + This function expects the :meth:`toolbox.mate`, :meth:`toolbox.mutate`, + and :meth:`toolbox.evaluate` aliases to be registered in the toolbox. + + You can register :meth:`toolbox.improve` for problem specific improvement. + :: + + def custom_improve(i, offspring): + # Some problem specific improvement for subproblem i + return offspring + + toolbox.register('improve', custom_improve) + + .. [Zhang2007] Q. Zhang and H. Li: MOEA/D: A multi-objective evolutionary + algorithm based on decomposition, IEEE Trans. on Evolutionary + Computation, Vol. 11, No. 6, pp. 712-731, 2007 + """ + moead = _algorithms.MOEAD(population, toolbox, weights, neighbour_size, + scalar_method, cxpb, mutpb, ngen, stats, verbose) + return moead.run() diff --git a/deap/tests/test_algorithms.py b/deap/tests/test_algorithms.py index 62e17a757..7b71ac4d9 100644 --- a/deap/tests/test_algorithms.py +++ b/deap/tests/test_algorithms.py @@ -231,3 +231,38 @@ def test_nsga3(): for ind in pop: assert not (any(numpy.asarray(ind) < BOUND_LOW) or any(numpy.asarray(ind) > BOUND_UP)) + + +@with_setup(setup_func_multi_obj, teardown_func) +def test_moead(): + NDIM = 5 + BOUND_LOW, BOUND_UP = 0.0, 1.0 + MU = 16 + NGEN = 100 + + toolbox = base.Toolbox() + toolbox.register("attr_float", random.uniform, BOUND_LOW, BOUND_UP) + toolbox.register("individual", tools.initRepeat, creator.__dict__[INDCLSNAME], toolbox.attr_float, NDIM) + toolbox.register("population", tools.initRepeat, list, toolbox.individual) + + toolbox.register("evaluate", benchmarks.zdt1) + toolbox.register("mate", tools.cxSimulatedBinaryBounded, low=BOUND_LOW, up=BOUND_UP, eta=20.0) + toolbox.register("mutate", tools.mutPolynomialBounded, low=BOUND_LOW, up=BOUND_UP, eta=20.0, indpb=1.0/NDIM) + + pop = toolbox.population(n=MU) + weights = [] + for i in range(MU): + weights.append([i / float(MU - 1), (MU - i - 1) / float(MU - 1)]) + + EP, logbook = algorithms.moead( + pop, toolbox, weights, 4, 'tchebycheff', + 0.9, 1.0, NGEN, None, False + ) + + hv = hypervolume(EP, [11.0, 11.0]) + # hv = 120.777 # Optimal value + + assert hv > HV_THRESHOLD, "Hypervolume is lower than expected %f < %f" % (hv, HV_THRESHOLD) + + for ind in EP: + assert not (any(numpy.asarray(ind) < BOUND_LOW) or any(numpy.asarray(ind) > BOUND_UP)) diff --git a/examples/ga/moead.py b/examples/ga/moead.py new file mode 100644 index 000000000..91719bc48 --- /dev/null +++ b/examples/ga/moead.py @@ -0,0 +1,110 @@ +# This file is part of DEAP. +# +# DEAP is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# DEAP is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with DEAP. If not, see . + +import array +import random +import json + +import numpy + +from math import sqrt + +from deap import algorithms +from deap import base +from deap import benchmarks +from deap.benchmarks.tools import diversity, convergence, hypervolume +from deap import creator +from deap import tools + +creator.create("FitnessMin", base.Fitness, weights=(-1.0, -1.0)) +creator.create("Individual", array.array, typecode='d', fitness=creator.FitnessMin) + +toolbox = base.Toolbox() + +# Problem definition +# Functions zdt1, zdt2, zdt3, zdt6 have bounds [0, 1] +BOUND_LOW, BOUND_UP = 0.0, 1.0 + +# Functions zdt4 has bounds x1 = [0, 1], xn = [-5, 5], with n = 2, ..., 10 +# BOUND_LOW, BOUND_UP = [0.0] + [-5.0]*9, [1.0] + [5.0]*9 + +# Functions zdt1, zdt2, zdt3 have 30 dimensions, zdt4 and zdt6 have 10 +NDIM = 30 + +def uniform(low, up, size=None): + try: + return [random.uniform(a, b) for a, b in zip(low, up)] + except TypeError: + return [random.uniform(a, b) for a, b in zip([low] * size, [up] * size)] + +toolbox.register("attr_float", uniform, BOUND_LOW, BOUND_UP, NDIM) +toolbox.register("individual", tools.initIterate, creator.Individual, toolbox.attr_float) +toolbox.register("population", tools.initRepeat, list, toolbox.individual) + +toolbox.register("evaluate", benchmarks.zdt1) +toolbox.register("mate", tools.cxSimulatedBinaryBounded, low=BOUND_LOW, up=BOUND_UP, eta=20.0) +toolbox.register("mutate", tools.mutPolynomialBounded, low=BOUND_LOW, up=BOUND_UP, eta=20.0, indpb=1.0/NDIM) + +def main(seed=None): + random.seed(seed) + + NGEN = 250 + MU = 100 + CXPB = 0.9 + MUTPB = 1.0 + NEIGHBOUR_SIZE = 10 + + stats = tools.Statistics(lambda ind: ind.fitness.values) + # stats.register("avg", numpy.mean, axis=0) + # stats.register("std", numpy.std, axis=0) + stats.register("min", numpy.min, axis=0) + stats.register("max", numpy.max, axis=0) + + pop = toolbox.population(n=MU) + weights = [] + for i in range(MU): + weights.append([i / float(MU - 1), (MU - i - 1) / float(MU - 1)]) + + EP, logbook = algorithms.moead( + pop, toolbox, weights, NEIGHBOUR_SIZE, 'tchebycheff', + CXPB, MUTPB, NGEN, stats, True + ) + + print("Final population hypervolume is %f" % hypervolume(EP, [11.0, 11.0])) + + return EP, logbook + +if __name__ == "__main__": + with open("pareto_front/zdt1_front.json") as optimal_front_data: + optimal_front = json.load(optimal_front_data) + # Use 500 of the 1000 points in the json file + optimal_front = sorted(optimal_front[i] for i in range(0, len(optimal_front), 2)) + + pop, stats = main() + pop.sort(key=lambda x: x.fitness.values) + + print(stats) + print("Convergence: ", convergence(pop, optimal_front)) + print("Diversity: ", diversity(pop, optimal_front[0], optimal_front[-1])) + + import matplotlib.pyplot as plt + import numpy + + front = numpy.array([ind.fitness.values for ind in pop]) + optimal_front = numpy.array(optimal_front) + plt.scatter(optimal_front[:,0], optimal_front[:,1], c="r") + plt.scatter(front[:,0], front[:,1], c="b") + plt.axis("tight") + plt.show() diff --git a/examples/ga/moead_onemax.py b/examples/ga/moead_onemax.py new file mode 100644 index 000000000..e78f2d4ba --- /dev/null +++ b/examples/ga/moead_onemax.py @@ -0,0 +1,86 @@ +# This file is part of DEAP. +# +# DEAP is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# DEAP is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with DEAP. If not, see . + +import array +import random + +import numpy +import matplotlib.pyplot as plt + +from deap import base +from deap import creator +from deap import tools +from deap import algorithms + +creator.create("FitnessMax", base.Fitness, weights=(1.0, 1.0)) +creator.create("Individual", array.array, typecode='b', fitness=creator.FitnessMax) + +toolbox = base.Toolbox() + +GENE_SIZE = 100 +ZERO_COUNT = 10 + +toolbox.register("attr_bool", random.randint, 0, 1) +toolbox.register("individual", tools.initRepeat, creator.Individual, toolbox.attr_bool, GENE_SIZE) +toolbox.register("population", tools.initRepeat, list, toolbox.individual) + +def calc(individual, check): + diff = 0 + for i in range(len(individual)): + if individual[i] != check[i]: + diff += 1 + return diff + +CHECK1 = [0] * 0 + [1] * GENE_SIZE +CHECK2 = [0] * ZERO_COUNT + [1] * (GENE_SIZE - ZERO_COUNT) + +def evalMoOneMax(individual): + l = len(individual) + return l - calc(individual, CHECK1), l - calc(individual, CHECK2) + +toolbox.register("evaluate", evalMoOneMax) +toolbox.register("mate", tools.cxTwoPoint) +toolbox.register("mutate", tools.mutFlipBit, indpb=0.01) + +MU = 100 +NEIGHBOUR_SIZE = 10 +NGEN = 100 + +CXPB = 0.5 +MUTPB = 0.2 + +stats = tools.Statistics(lambda ind: ind.fitness.values) +stats.register("avg", numpy.mean, axis=0) +stats.register("std", numpy.std, axis=0) +stats.register("min", numpy.min, axis=0) +stats.register("max", numpy.max, axis=0) + +random.seed(64) + +pop = toolbox.population(n=MU) +weights = [] +for i in range(MU): + weights.append([i / float(MU - 1), (MU - i - 1) / float(MU - 1)]) + +EP, logbook = algorithms.moead(pop, toolbox, weights, + neighbour_size=NEIGHBOUR_SIZE, + scalar_method='tchebycheff', + cxpb=CXPB, mutpb=MUTPB, ngen=NGEN, + stats=stats, verbose=True) + +front = numpy.array([ind.fitness.values for ind in EP]) +plt.scatter(front[:,0], front[:,1], c="b") +plt.axis("tight") +plt.show()