diff --git a/HISTORY.md b/HISTORY.md index 024f113a1..cc6fc7041 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -28,6 +28,9 @@ * Make Callback flexible for MultiObjective Optimizers ([#289](https://github.com/mlpack/ensmallen/pull/289)). + * Add MOEA-D/DE Optimizer + ([#269](https://github.com/mlpack/ensmallen/pull/269)). + ### ensmallen 2.16.1: "Severely Dented Can Of Polyurethane" ###### 2021-03-02 * Fix test compilation issue when `ENS_USE_OPENMP` is set diff --git a/doc/function_types.md b/doc/function_types.md index 4cf4dcc1b..28139cce2 100644 --- a/doc/function_types.md +++ b/doc/function_types.md @@ -888,6 +888,7 @@ front. The following optimizers can be used with multi-objective functions: - [NSGA2](#nsga2) +- [MOEA/D-DE](#moead) ## Constrained functions diff --git a/doc/optimizers.md b/doc/optimizers.md index 2317a92c1..103ff855d 100644 --- a/doc/optimizers.md +++ b/doc/optimizers.md @@ -1563,6 +1563,66 @@ optimizer.Optimize(f, coordinates); * [SGD in Wikipedia](https://en.wikipedia.org/wiki/Stochastic_gradient_descent) * [Differentiable separable functions](#differentiable-separable-functions) +## MOEA/D-DE +*An optimizer for arbitrary multi-objective functions.* +MOEA/D-DE (Multi Objective Evolutionary Algorithm based on Decomposition - Differential Evolution) is a multi +objective optimization algorithm. It works by decomposing the problem into a number of scalar optimization +subproblems which are solved simultaneously per generation. MOEA/D in itself is a framework, this particular +algorithm uses Differential Crossover followed by Polynomial Mutation to create offsprings which are then +decomposed to form a Single Objective Problem. A diversity preserving mechanism is also employed which encourages +a varied set of solution. + +#### Constructors +* `MOEAD()` +* `MOEAD(`_`populationSize, maxGenerations, crossoverProb, neighborProb, neighborSize, distributionIndex, differentialWeight, maxReplace, epsilon, lowerBound, upperBound`_`)` + +#### Attributes + +| **type** | **name** | **description** | **default** | +|----------|----------|-----------------|-------------| +| `size_t` | **`populationSize`** | The number of candidates in the population. | `150` | +| `size_t` | **`maxGenerations`** | The maximum number of generations allowed. | `300` | +| `double` | **`crossoverProb`** | Probability that a crossover will occur. | `1.0` | +| `double` | **`neighborProb`** | The probability of sampling from neighbor. | `0.9` | +| `size_t` | **`neighborSize`** | The number of nearest-neighbours to consider per weight vector. | `20` | +| `double` | **`distributionIndex`** | The crowding degree of the mutation. | `20` | +| `double` | **`differentialWeight`** | Amplification factor of the differentiation. | `0.5` | +| `size_t` | **`maxReplace`** | The limit of solutions allowed to be replaced by a child. | `2`| +| `double` | **`epsilon`** | Handles numerical stability after weight initialization. | `1E-10`| +| `double`, `arma::vec` | **`lowerBound`** | Lower bound of the coordinates on the coordinates of the whole population during the search process. | `0` | +| `double`, `arma::vec` | **`upperBound`** | Lower bound of the coordinates on the coordinates of the whole population during the search process. | `1` | + +Attributes of the optimizer may also be changed via the member methods +`PopulationSize()`, `MaxGenerations()`, `CrossoverRate()`, `NeighborProb()`, `NeighborSize()`, `DistributionIndex()`, +`DifferentialWeight()`, `MaxReplace()`, `Epsilon()`, `LowerBound()` and `UpperBound()`. + +#### Examples: + +
+Click to collapse/expand example code. + + +```c++ +SchafferFunctionN1 SCH; +arma::vec lowerBound("-10 -10"); +arma::vec upperBound("10 10"); +MOEAD opt(150, 300, 1.0, 0.9, 20, 20, 0.5, 2, 1E-10, lowerBound, upperBound); +typedef decltype(SCH.objectiveA) ObjectiveTypeA; +typedef decltype(SCH.objectiveB) ObjectiveTypeB; +arma::mat coords = SCH.GetInitialPoint(); +std::tuple objectives = SCH.GetObjectives(); +// obj will contain the minimum sum of objectiveA and objectiveB found on the best front. +double obj = opt.Optimize(objectives, coords); +// Now obtain the best front. +arma::cube bestFront = opt.ParetoFront(); +``` +
+ +#### See also +* [MOEA/D-DE Algorithm](https://ieeexplore.ieee.org/document/4633340) +* [Multi-objective Functions in Wikipedia](https://en.wikipedia.org/wiki/Test_functions_for_optimization#Test_functions_for_multi-objective_optimization) +* [Multi-objective functions](#multi-objective-functions) + ## NSGA2 *An optimizer for arbitrary multi-objective functions.* @@ -1619,7 +1679,7 @@ std::tuple objectives = SCH.GetObjectives(); // obj will contain the minimum sum of objectiveA and objectiveB found on the best front. double obj = opt.Optimize(objectives, coords); // Now obtain the best front. -std::vector bestFront = opt.Front(); +arma::cube bestFront = opt.Front(); ``` diff --git a/include/ensmallen.hpp b/include/ensmallen.hpp index 75316514a..0f6520708 100644 --- a/include/ensmallen.hpp +++ b/include/ensmallen.hpp @@ -103,6 +103,7 @@ #include "ensmallen_bits/katyusha/katyusha.hpp" #include "ensmallen_bits/lbfgs/lbfgs.hpp" #include "ensmallen_bits/lookahead/lookahead.hpp" +#include "ensmallen_bits/moead/moead.hpp" #include "ensmallen_bits/nsga2/nsga2.hpp" #include "ensmallen_bits/padam/padam.hpp" #include "ensmallen_bits/parallel_sgd/parallel_sgd.hpp" diff --git a/include/ensmallen_bits/moead/moead.hpp b/include/ensmallen_bits/moead/moead.hpp new file mode 100644 index 000000000..9fa34cc09 --- /dev/null +++ b/include/ensmallen_bits/moead/moead.hpp @@ -0,0 +1,326 @@ +/** + * @file moead.hpp + * @author Utkarsh Rai + * @author Nanubala Gnana Sai + * + * MOEA/D, Multi Objective Evolutionary Algorithm based on Decompositon is a + * multi objective optimization algorithm. It employs evolutionary algorithms, + * to find better solutions by iterating on the previous solutions and + * decomposition approaches, to convert the multi objective problem to a single + * objective one, to find the best Pareto Front for the given problem. + * + * ensmallen is free software; you may redistribute it and/or modify it under + * the terms of the 3-clause BSD license. You should have received a copy of + * the 3-clause BSD license along with ensmallen. If not, see + * http://www.opensource.org/licenses/BSD-3-Clause for more information. + */ + +#ifndef ENSMALLEN_MOEAD_MOEAD_HPP +#define ENSMALLEN_MOEAD_MOEAD_HPP + +namespace ens { + +/** + * This class implements the MOEA/D algorithm with Differential Evolution + * crossover. Step numbers used in different parts of the implementation + * correspond to the step number used in the original algorithm by the author. + * + * For more information, see the following: + * @code + * @article{article, + * author = {Zhang, Qingfu and Li, Hui}, + * year = {2008}, + * pages = {712 - 731}, + * title = {MOEA/D: A Multiobjective Evolutionary Algorithm Based on + * Decomposition}, + * journal = {Evolutionary Computation, IEEE Transactions on}, + * + * @article{4633340, + * author={H. {Li} and Q. {Zhang}}, + * year={2009}, + * pages={284-302},} + * title={Multiobjective Optimization Problems With Complicated Pareto Sets, MOEA/D and NSGA-II}, + * journal={IEEE Transactions on Evolutionary Computation}, + * @endcode + * + * MOEA/D can optimize arbitrary multi-objective functions. For more details, + * see the documentation on function types included with this distribution or + * on the ensmallen website. + */ +class MOEAD { + public: + /** + * Constructor for the MOEA/D optimizer. + * + * The default values provided here are not necessarily suitable for a + * given function. Therefore, it is highly recommended to adjust the + * parameters according to the problem. + * + * @param populationSize The number of elements in the population. + * @param maxGenerations The maximum number of generations allowed. + * @param crossoverProb The probability that a crossover will occur. + * @param neighborProb The probability of sampling from neighbor. + * @param neighborSize The number of nearest neighbours of weights + * to find. + * @param distributionIndex The crowding degree of the mutation. + * @param differentialWeight A parameter used in the mutation of candidate + * solutions controls amplification factor of the differentiation. + * @param maxReplace The limit of solutions allowed to be replaced by a child. + * @param epsilon Handle numerical stability after weight initialization. + * @param lowerBound The lower bound on each variable of a member + * of the variable space. + * @param upperBound The upper bound on each variable of a member + * of the variable space. + */ + MOEAD(const size_t populationSize = 150, + const size_t maxGenerations = 300, + const double crossoverProb = 1.0, + const double neighborProb = 0.9, + const size_t neighborSize = 20, + const double distributionIndex = 20, + const double differentialWeight = 0.5, + const size_t maxReplace = 2, + const double epsilon = 1E-10, + const arma::vec& lowerBound = arma::zeros(1, 1), + const arma::vec& upperBound = arma::ones(1, 1)); + + /** + * Constructor for the MOEA/D optimizer. This constructor is provides an + * overload to use lowerBound and upperBound as doubles, in case all the + * variables in the problem have the same limits. + * + * The default values provided here are not necessarily suitable for a + * given function. Therefore, it is highly recommended to adjust the + * parameters according to the problem. + * + * @param populationSize The number of elements in the population. + * @param maxGenerations The maximum number of generations allowed. + * @param crossoverProb The probability that a crossover will occur. + * @param neighborProb The probability of sampling from neighbor. + * @param neighborSize The number of nearest neighbours of weights + * to find. + * @param distributionIndex The crowding degree of the mutation. + * @param differentialWeight A parameter used in the mutation of candidate + * solutions controls amplification factor of the differentiation. + * @param maxReplace The limit of solutions allowed to be replaced by a child. + * @param epsilon Handle numerical stability after weight initialization. + * @param lowerBound The lower bound on each variable of a member + * of the variable space. + * @param upperBound The upper bound on each variable of a member + * of the variable space. + */ + MOEAD(const size_t populationSize = 150, + const size_t maxGenerations = 300, + const double crossoverProb = 1.0, + const double neighborProb = 0.9, + const size_t neighborSize = 20, + const double distributionIndex = 20, + const double differentialWeight = 0.5, + const size_t maxReplace = 2, + const double epsilon = 1E-10, + const double lowerBound = 0, + const double upperBound = 1); + + /** + * Optimize a set of objectives. The initial population is generated + * using the initial point. The output is the best generated front. + * + * @tparam MatType The type of matrix used to store coordinates. + * @tparam ArbitraryFunctionType The type of objective function. + * @tparam CallbackTypes Types of callback function. + * @param objectives std::tuple of the objective functions. + * @param iterate The initial reference point for generating population. + * @param callbacks The callback functions. + */ + template + typename MatType::elem_type Optimize(std::tuple& objectives, + MatType& iterate, + CallbackTypes&&... callbacks); + + //! Retrieve population size. + size_t PopulationSize() const { return populationSize; } + //! Modify the population size. + size_t& PopulationSize() { return populationSize; } + + //! Retrieve number of generations. + size_t MaxGenerations() const { return maxGenerations; } + //! Modify the number of generations. + size_t& MaxGenerations() { return maxGenerations; } + + //! Retrieve crossover rate. + double CrossoverRate() const { return crossoverProb; } + //! Modify the crossover rate. + double& CrossoverRate() { return crossoverProb; } + + //! Retrieve size of the weight neighbor. + size_t NeighborSize() const { return neighborSize; } + //! Modify the size of the weight neighbor. + size_t& NeighborSize() { return neighborSize; } + + //! Retrieve value of the distribution index. + double DistributionIndex() const { return distributionIndex; } + //! Modify the value of the distribution index. + double& DistributionIndex() { return distributionIndex; } + + //! Retrieve value of neighbor probability. + double NeighborProb() const { return neighborProb; } + //! Modify the value of neigbourhood probability. + double& NeighborProb() { return neighborProb; } + + //! Retrieve value of scaling factor. + double DifferentialWeight() const { return differentialWeight; } + //! Modify the value of scaling factor. + double& DifferentialWeight() { return differentialWeight; } + + //! Retrieve value of maxReplace. + size_t MaxReplace() const { return maxReplace; } + //! Modify value of maxReplace. + size_t& MaxReplace() { return maxReplace; } + + //! Retrieve value of epsilon. + double Epsilon() const { return epsilon; } + //! Modify value of maxReplace. + double& Epsilon() { return epsilon; } + + //! Retrieve value of lowerBound. + const arma::vec& LowerBound() const { return lowerBound; } + //! Modify value of lowerBound. + arma::vec& LowerBound() { return lowerBound; } + + //! Retrieve value of upperBound. + const arma::vec& UpperBound() const { return upperBound; } + //! Modify value of upperBound. + arma::vec& UpperBound() { return upperBound; } + + //! Retrieve the Pareto optimal points in variable space. This returns an empty cube + //! until `Optimize()` has been called. + const arma::cube& ParetoSet() const { return paretoSet; } + + //! Retrieve the best front (the Pareto frontier). This returns an empty cube until + //! `Optimize()` has been called. + const arma::cube& ParetoFront() const { return paretoFront; } + + + private: + /** + * @brief Randomly selects two members from the population. + * + * @param subProblemIdx Index of the current subproblem. + * @param neighborSize A matrix containing indices of the neighbors. + * @return std::tuple The chosen pair of indices. + */ + std::tuple Mating(size_t subProblemIdx, + const arma::umat& neighborSize, + bool sampleNeighbor); + + /** + * Mutate the child formed by the crossover of two random members of the + * population. Uses polynomial mutation. + * + * @tparam MatType The type of matrix used to store coordinates. + * @param child The candidate to be mutated. + * @param mutationRate The probability of mutation. + * @param lowerBound The lower bound on each variable in the matrix. + * @param upperBound The upper bound on each variable in the matrix. + * @return The mutated child. + */ + template + void Mutate(MatType& child, + double mutationRate, + const MatType& lowerBound, + const MatType& upperBound); + + /** + * Decompose the multi objective problem to a single objective problem. + * + * @param subProblemWeight The Decomposition weight vector of the current subproblem. + * @param idealPoint The reference point z for a decomposition problem. + * @param candidateFitness The fitness value of the candidate. + * @return The real value obtained from the decomposed function. + */ + template + ElemType DecomposeObjectives(const arma::Col& subProblemWeight, + const arma::Col& idealPoint, + const arma::Col& candidateFitness); + + /** + * Evaluate objectives for the elite population. + * + * @tparam ArbitraryFunctionType std::tuple of multiple function types. + * @tparam MatType Type of matrix to optimize. + * @param population The elite population. + * @param objectives The set of objectives. + * @param calculatedObjectives Vector to store calculated objectives. + */ + template + typename std::enable_if::type + EvaluateObjectives( + std::vector&, + std::tuple&, + std::vector >&); + + template + typename std::enable_if::type + EvaluateObjectives( + std::vector& population, + std::tuple& objectives, + std::vector >& + calculatedObjectives); + + //! Size of the population. + size_t populationSize; + + //! Maximum number of generations before termination criteria is met. + size_t maxGenerations; + + //! Probability of crossover between two members. + double crossoverProb; + + //! The probability that two elements will be chosen from the neighbor. + double neighborProb; + + //! Number of nearest neighbours of weights to consider. + size_t neighborSize; + + //! The crowding degree of the mutation. Higher value produces a mutant + //! resembling its parent. + double distributionIndex; + + //! Amplification factor for differentiation. + double differentialWeight; + + //! Maximum number of childs which can replace the parent. Higher value + //! leads to a loss of diversity. + size_t maxReplace; + + //! A small numeric value to be added to the weights after initialization. + //! Prevents zero value inside inited weights. + double epsilon; + + //! Lower bound on each variable in the variable space. + arma::vec lowerBound; + + //! Upper bound on each variable in the variable space. + arma::vec upperBound; + + //! The set of all the Pareto optimal points. + //! Stored after Optimize() is called. + arma::cube paretoSet; + + //! The set of all the Pareto optimal objective vectors. + //! Stored after Optimize() is called. + arma::cube paretoFront; +}; + +} // namespace ens + +// Include implementation. +#include "moead_impl.hpp" + +#endif diff --git a/include/ensmallen_bits/moead/moead_impl.hpp b/include/ensmallen_bits/moead/moead_impl.hpp new file mode 100644 index 000000000..26d08aa49 --- /dev/null +++ b/include/ensmallen_bits/moead/moead_impl.hpp @@ -0,0 +1,429 @@ +/** + * @file moead_impl.hpp + * @author Utkarsh Rai + * @author Nanubala Gnana Sai + * + * Implementation of the MOEA/D-DE algorithm. Used for multi-objective + * optimization problems on arbitrary functions. + * + * ensmallen is free software; you may redistribute it and/or modify it under + * the terms of the 3-clause BSD license. You should have received a copy of + * the 3-clause BSD license along with ensmallen. If not, see + * http://www.opensource.org/licenses/BSD-3-Clause for more Information. + */ + +#ifndef ENSMALLEN_MOEAD_MOEAD_IMPL_HPP +#define ENSMALLEN_MOEAD_MOEAD_IMPL_HPP + +#include "moead.hpp" +#include + +namespace ens { + +inline MOEAD::MOEAD(const size_t populationSize, + const size_t maxGenerations, + const double crossoverProb, + const double neighborProb, + const size_t neighborSize, + const double distributionIndex, + const double differentialWeight, + const size_t maxReplace, + const double epsilon, + const arma::vec& lowerBound, + const arma::vec& upperBound) : + populationSize(populationSize), + maxGenerations(maxGenerations), + crossoverProb(crossoverProb), + neighborProb(neighborProb), + neighborSize(neighborSize), + distributionIndex(distributionIndex), + differentialWeight(differentialWeight), + maxReplace(maxReplace), + epsilon(epsilon), + lowerBound(lowerBound), + upperBound(upperBound) + { /* Nothing to do here. */ } + +inline MOEAD::MOEAD(const size_t populationSize, + const size_t maxGenerations, + const double crossoverProb, + const double neighborProb, + const size_t neighborSize, + const double distributionIndex, + const double differentialWeight, + const size_t maxReplace, + const double epsilon, + const double lowerBound, + const double upperBound) : + populationSize(populationSize), + maxGenerations(maxGenerations), + crossoverProb(crossoverProb), + neighborProb(neighborProb), + neighborSize(neighborSize), + distributionIndex(distributionIndex), + differentialWeight(differentialWeight), + maxReplace(maxReplace), + epsilon(epsilon), + lowerBound(lowerBound * arma::ones(1, 1)), + upperBound(upperBound * arma::ones(1, 1)) + { /* Nothing to do here. */ } + +//! Optimize the function. +template +typename MatType::elem_type MOEAD::Optimize(std::tuple& objectives, + MatType& iterateIn, + CallbackTypes&&... callbacks) +{ + // Population Size must be at least 3 for MOEA/D-DE to work. + if (populationSize < 3) + { + throw std::logic_error("MOEA/D-DE::Optimize(): population size should be at least" + " 3!"); + } + + // Convenience typedefs. + typedef typename MatType::elem_type ElemType; + typedef typename MatTypeTraits::BaseMatType BaseMatType; + + BaseMatType& iterate = (BaseMatType&) iterateIn; + + // Make sure that we have the methods that we need. Long name... + traits::CheckArbitraryFunctionTypeAPI(); + RequireDenseFloatingPointType(); + + if (neighborSize < 2) + { + throw std::invalid_argument( + "neighborSize should be atleast 2, however " + + std::to_string(neighborSize) + " was detected." + ); + } + + if (neighborSize > populationSize - 1u) + { + std::ostringstream oss; + oss << "MOEAD::Optimize(): " << "neighborSize is " << neighborSize + << " but populationSize is " << populationSize << "(should be" + << " atleast " << (neighborSize + 1u) << ")" << std::endl; + throw std::logic_error(oss.str()); + } + + // Check if lower bound is a vector of a single dimension. + if (lowerBound.n_rows == 1) + lowerBound = lowerBound(0, 0) * arma::ones(iterate.n_rows, iterate.n_cols); + + // Check if upper bound is a vector of a single dimension. + if (upperBound.n_rows == 1) + upperBound = upperBound(0, 0) * arma::ones(iterate.n_rows, iterate.n_cols); + + // Check the dimensions of lowerBound and upperBound. + assert(lowerBound.n_rows == iterate.n_rows && "The dimensions of " + "lowerBound are not the same as the dimensions of iterate."); + assert(upperBound.n_rows == iterate.n_rows && "The dimensions of " + "upperBound are not the same as the dimensions of iterate."); + + const size_t numObjectives = sizeof...(ArbitraryFunctionType); + const size_t numVariables = iterate.n_rows; + + //! Useful temporaries for float-like comparisons. + const BaseMatType castedLowerBound = arma::conv_to::from(lowerBound); + const BaseMatType castedUpperBound = arma::conv_to::from(upperBound); + + // Controls early termination of the optimization process. + bool terminate = false; + + // The weight matrix. Each vector represents a decomposition subproblem (M X N). + const BaseMatType weights = BaseMatType(numObjectives, populationSize, + arma::fill::randu) + epsilon; + + // 1.1 Storing the indices of nearest neighbors of each weight vector. + arma::umat neighborIndices(neighborSize, populationSize); + for (size_t i = 0; i < populationSize; ++i) + { + // Cache the distance between weights[i] and other weights. + const arma::Row distances = + arma::sqrt(arma::sum(arma::square(weights.col(i) - weights.each_col()))); + arma::uvec sortedIndices = arma::stable_sort_index(distances); + // Ignore distance from self. + neighborIndices.col(i) = sortedIndices(arma::span(1, neighborSize)); + } + + // 1.2 Random generation of the initial population. + std::vector population(populationSize); + for (BaseMatType& individual : population) + { + individual = arma::randu( + iterate.n_rows, iterate.n_cols) - 0.5 + iterate; + + // Constrain all genes to be within bounds. + individual = arma::min(arma::max(individual, castedLowerBound), castedUpperBound); + } + + Info << "MOEA/D-DE initialized successfully. Optimization started." << std::endl; + + std::vector> populationFitness(populationSize); + std::fill(populationFitness.begin(), populationFitness.end(), + arma::Col(numObjectives, arma::fill::zeros)); + EvaluateObjectives(population, objectives, populationFitness); + + // 1.3 Initialize the ideal point z. + arma::Col idealPoint(numObjectives); + idealPoint.fill(std::numeric_limits::max()); + + for (const arma::Col& individualFitness : populationFitness) + idealPoint = arma::min(idealPoint, individualFitness); + + terminate |= Callback::BeginOptimization(*this, objectives, iterate, callbacks...); + + // 2 The main loop. + for (size_t generation = 1; generation <= maxGenerations && !terminate; ++generation) + { + // Shuffle indexes of subproblems. + const arma::uvec shuffle = arma::shuffle( + arma::linspace(0, populationSize - 1, populationSize)); + for (size_t subProblemIdx : shuffle) + { + // 2.1 Randomly select two indices in neighborIndices[subProblemIdx] and use them + // to make a child. + size_t r1, r2, r3; + r1 = subProblemIdx; + // Randomly choose to sample from the population or the neighbors. + const bool sampleNeighbor = arma::randu() < neighborProb; + std::tie(r2, r3) = + Mating(subProblemIdx, neighborIndices, sampleNeighbor); + + // 2.2 - 2.3 Reproduction and Repair: Differential Operator followed by + // Polynomial Mutation. + BaseMatType candidate(iterate.n_rows, iterate.n_cols); + + for (size_t geneIdx = 0; geneIdx < numVariables; ++geneIdx) + { + if (arma::randu() < crossoverProb) + { + candidate(geneIdx) = population[r1](geneIdx) + + differentialWeight * (population[r2](geneIdx) - + population[r3](geneIdx)); + + // Boundary conditions. + if (candidate(geneIdx) < castedLowerBound(geneIdx)) + { + candidate(geneIdx) = castedLowerBound(geneIdx) + + arma::randu() * (population[r1](geneIdx) - castedLowerBound(geneIdx)); + } + if (candidate(geneIdx) > castedUpperBound(geneIdx)) + { + candidate(geneIdx) = castedUpperBound(geneIdx) - + arma::randu() * (castedUpperBound(geneIdx) - population[r1](geneIdx)); + } + } + else + candidate(geneIdx) = population[r1](geneIdx); + } + + Mutate(candidate, 1.0 / static_cast(numVariables), + castedLowerBound, castedUpperBound); + + arma::Col candidateFitness(numObjectives); + //! Creating temp vectors to pass to EvaluateObjectives. + std::vector candidateContainer { candidate }; + std::vector> fitnessContainer { candidateFitness }; + EvaluateObjectives(candidateContainer, objectives, fitnessContainer); + candidateFitness = std::move(fitnessContainer[0]); + //! Flush out the dummy containers. + fitnessContainer.clear(); + candidateContainer.clear(); + + // 2.4 Update of ideal point. + idealPoint = arma::min(idealPoint, candidateFitness); + + // 2.5 Update of the population. + size_t replaceCounter = 0; + const size_t sampleSize = sampleNeighbor ? neighborSize : populationSize; + + const arma::uvec idxShuffle = arma::shuffle( + arma::linspace(0, sampleSize - 1, sampleSize)); + + for (size_t idx : idxShuffle) + { + // Preserve diversity by controlling replacement of neighbors + // by child solution. + if (replaceCounter >= maxReplace) + break; + + const size_t pick = sampleNeighbor ? + neighborIndices(idx, subProblemIdx) : idx; + + const ElemType candidateDecomposition = DecomposeObjectives( + weights.col(pick), idealPoint, candidateFitness); + const ElemType parentDecomposition = DecomposeObjectives( + weights.col(pick), idealPoint, populationFitness[pick]); + + if (candidateDecomposition < parentDecomposition) + { + population[pick] = candidate; + populationFitness[pick] = candidateFitness; + ++replaceCounter; + } + } + } // End of pass over all subproblems. + + // The final population itself is the best front. + const arma::uvec frontIndices = arma::shuffle( + arma::linspace(0, populationSize - 1, populationSize)); + + terminate |= Callback::GenerationalStepTaken(*this, objectives, iterate, + populationFitness, frontIndices, callbacks...); + } // End of pass over all the generations. + + // Set the candidates from the Pareto Set as the output. + paretoSet.resize(population[0].n_rows, population[0].n_cols, population.size()); + + // The Pareto Front is stored, can be obtained via ParetoSet() getter. + for (size_t solutionIdx = 0; solutionIdx < population.size(); ++solutionIdx) + { + paretoSet.slice(solutionIdx) = + arma::conv_to::from(population[solutionIdx]); + } + + EvaluateObjectives(population, objectives, populationFitness); + // Set the candidates from the Pareto Front as the output. + paretoFront.resize(populationFitness[0].n_rows, populationFitness[0].n_cols, + populationFitness.size()); + + // The Pareto Front is stored, can be obtained via ParetoFront() getter. + for (size_t solutionIdx = 0; solutionIdx < populationFitness.size(); ++solutionIdx) + { + paretoFront.slice(solutionIdx) = + arma::conv_to::from(populationFitness[solutionIdx]); + } + + Callback::EndOptimization(*this, objectives, iterate, callbacks...); + + ElemType performance = std::numeric_limits::max(); + + for (size_t geneIdx = 0; geneIdx < numObjectives; ++geneIdx) + { + if (arma::accu(populationFitness[geneIdx]) < performance) + performance = arma::accu(populationFitness[geneIdx]); + } + + return performance; +} + +//! Randomly chooses to select from parents or neighbors. +inline std::tuple +MOEAD::Mating(size_t subProblemIdx, + const arma::umat& neighborIndices, + bool sampleNeighbor) +{ + //! Indexes of two points from the sample space. + size_t pointA = sampleNeighbor + ? neighborIndices( + arma::randi(arma::distr_param(0, neighborSize - 1u)), subProblemIdx) + : arma::randi(arma::distr_param(0, populationSize - 1u)); + + size_t pointB = sampleNeighbor + ? neighborIndices( + arma::randi(arma::distr_param(0, neighborSize - 1u)), subProblemIdx) + : arma::randi(arma::distr_param(0, populationSize - 1u)); + + //! If the sampled points are equal, then modify one of them + //! within reasonable bounds. + if (pointA == pointB) + { + if (pointA == populationSize - 1u) + --pointA; + else + ++pointA; + } + + return std::make_tuple(pointA, pointB); +} + +//! Perform Polynomial mutation of the candidate. +template +inline void MOEAD::Mutate(MatType& candidate, + double mutationRate, + const MatType& lowerBound, + const MatType& upperBound) +{ + const size_t numVariables = candidate.n_rows; + for (size_t geneIdx = 0; geneIdx < numVariables; ++geneIdx) + { + // Should this gene be mutated? + if (arma::randu() > mutationRate) + continue; + + const double geneRange = upperBound(geneIdx) - lowerBound(geneIdx); + // Normalised distance from the bounds. + const double lowerDelta = (candidate(geneIdx) - lowerBound(geneIdx)) / geneRange; + const double upperDelta = (upperBound(geneIdx) - candidate(geneIdx)) / geneRange; + const double mutationPower = 1. / (distributionIndex + 1.0); + const double rand = arma::randu(); + double value, perturbationFactor; + if (rand < 0.5) + { + value = 2.0 * rand + (1.0 - 2.0 * rand) * + std::pow(upperDelta, distributionIndex + 1.0); + perturbationFactor = std::pow(value, mutationPower) - 1.0; + } + else + { + value = 2.0 * (1.0 - rand) + 2.0 *(rand - 0.5) * + std::pow(lowerDelta, distributionIndex + 1.0); + perturbationFactor = 1.0 - std::pow(value, mutationPower); + } + + candidate(geneIdx) += perturbationFactor * geneRange; + } + //! Enforce bounds. + candidate = arma::min(arma::max(candidate, lowerBound), upperBound); +} + +//! Calculate the output for single objective function using the Tchebycheff +//! approach. +template +inline ElemType MOEAD::DecomposeObjectives(const arma::Col& subProblemWeight, + const arma::Col& idealPoint, + const arma::Col& candidateFitness) +{ + return arma::max(subProblemWeight % arma::abs(candidateFitness - idealPoint)); +} + +//! No objectives to evaluate. +template +typename std::enable_if::type +MOEAD::EvaluateObjectives( + std::vector&, + std::tuple&, + std::vector >&) +{ + // Nothing to do here. +} + +//! Evaluate the objectives for the entire population. +template +typename std::enable_if::type +MOEAD::EvaluateObjectives( + std::vector& population, + std::tuple& objectives, + std::vector >& calculatedObjectives) +{ + for (size_t i = 0; i < population.size(); i++) + { + calculatedObjectives[i](I) = std::get(objectives).Evaluate(population[i]); + EvaluateObjectives(population, objectives, + calculatedObjectives); + } +} + +} // namespace ens + +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6b55d4a4a..e17ec5a34 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,6 +23,7 @@ set(ENSMALLEN_TESTS_SOURCES line_search_test.cpp lookahead_test.cpp lrsdp_test.cpp + moead_test.cpp momentum_sgd_test.cpp nesterov_momentum_sgd_test.cpp nsga2_test.cpp diff --git a/tests/callbacks_test.cpp b/tests/callbacks_test.cpp index 90c1ddc0b..924278f38 100644 --- a/tests/callbacks_test.cpp +++ b/tests/callbacks_test.cpp @@ -399,6 +399,18 @@ TEST_CASE("NSGA2CallbacksFullFunctionTest", "[CallbackTest]") true, true, false, false, false, true); } +/** + * Make sure we invoke all callbacks (MOEA/D-DE). + */ +TEST_CASE("MOEADCallbacksFullFunctionTest", "[CallbackTest]") +{ + arma::vec lowerBound = {-1000}; + arma::vec upperBound = {1000}; + MOEAD optimizer(150, 300, 1.0, 0.9, 20, 20, 0.5, 2, 1E-10, lowerBound, upperBound); + CallbacksFullMultiobjectiveFunctionTest(optimizer, false, false, false, false, + true, true, false, false, false, true); +} + /** * Make sure we invoke all callbacks (Lookahead). */ diff --git a/tests/moead_test.cpp b/tests/moead_test.cpp new file mode 100644 index 000000000..8e89872b0 --- /dev/null +++ b/tests/moead_test.cpp @@ -0,0 +1,500 @@ +/** + * @file moead_test.cpp + * @author Sayan Goswami + * @author Utkarsh Rai + * @author Nanubala Gnana Sai + * + * ensmallen is free software; you may redistribute it and/or modify it under + * the terms of the 3-clause BSD license. You should have received a copy of + * the 3-clause BSD license along with ensmallen. If not, see + * http://www.opensource.org/licenses/BSD-3-Clause for more information. + */ + +#include +#include "catch.hpp" +#include "test_function_tools.hpp" + +using namespace ens; +using namespace ens::test; +using namespace std; + +/** + * Checks if low <= value <= high. Used by MOEADFonsecaFlemingTest. + * + * @param value The value being checked. + * @param low The lower bound. + * @param high The upper bound. + * @tparam The type of elements in the population set. + * @return true if value lies in the range [low, high]. + * @return false if value does not lie in the range [low, high]. + */ +template +bool IsInBounds(const ElemType& value, const ElemType& low, const ElemType& high) +{ + ElemType roundoff = 0.1; + return !(value < (low - roundoff)) && !((high + roundoff) < value); +} + +/** + * Optimize for the Schaffer N.1 function using NSGA-II optimizer. + * Tests for data of type double. + */ +TEST_CASE("MOEADSchafferN1DoubleTest", "[MOEADTest]") +{ + SchafferFunctionN1 SCH; + const double lowerBound = -1000; + const double upperBound = 1000; + const double expectedLowerBound = 0.0; + const double expectedUpperBound = 2.0; + + MOEAD opt( + 150, // Population size. + 300, // Max generations. + 1.0, // Crossover probability. + 0.9, // Probability of sampling from neighbor. + 20, // Neighborhood size. + 20, // Perturbation index. + 0.5, // Differential weight. + 2, // Max childrens to replace parents. + 1E-10, // epsilon. + lowerBound, // Lower bound. + upperBound // Upper bound. + ); + + typedef decltype(SCH.objectiveA) ObjectiveTypeA; + typedef decltype(SCH.objectiveB) ObjectiveTypeB; + + // We allow a few trials in case of poor convergence. + bool success = false; + for (size_t trial = 0; trial < 3; ++trial) + { + arma::mat coords = SCH.GetInitialPoint(); + std::tuple objectives = SCH.GetObjectives(); + + opt.Optimize(objectives, coords); + arma::cube paretoSet= opt.ParetoSet(); + + bool allInRange = true; + + for (size_t solutionIdx = 0; solutionIdx < paretoSet.n_slices; ++solutionIdx) + { + double val = arma::as_scalar(paretoSet.slice(solutionIdx)); + if (!IsInBounds(val, expectedLowerBound, expectedUpperBound)) + { + allInRange = false; + break; + } + } + + if (allInRange) + { + success = true; + break; + } + } + + REQUIRE(success == true); +} + +/** + * Optimize for the Schaffer N.1 function using NSGA-II optimizer. + * Tests for data of type double. + */ +TEST_CASE("MOEADSchafferN1TestVectorDoubleBounds", "[MOEADTest]") +{ + // This test can be a little flaky, so we try it a few times. + SchafferFunctionN1 SCH; + const arma::vec lowerBound = {-1000}; + const arma::vec upperBound = {1000}; + const double expectedLowerBound = 0.0; + const double expectedUpperBound = 2.0; + + MOEAD opt( + 150, // Population size. + 300, // Max generations. + 1.0, // Crossover probability. + 0.9, // Probability of sampling from neighbor. + 20, // Neighborhood size. + 20, // Perturbation index. + 0.5, // Differential weight. + 2, // Max childrens to replace parents. + 1E-10, // epsilon. + lowerBound, // Lower bound. + upperBound // Upper bound. + ); + + typedef decltype(SCH.objectiveA) ObjectiveTypeA; + typedef decltype(SCH.objectiveB) ObjectiveTypeB; + + bool success = false; + for (size_t trial = 0; trial < 3; ++trial) + { + arma::mat coords = SCH.GetInitialPoint(); + std::tuple objectives = SCH.GetObjectives(); + + opt.Optimize(objectives, coords); + arma::cube paretoSet = opt.ParetoSet(); + + bool allInRange = true; + + for (size_t solutionIdx = 0; solutionIdx < paretoSet.n_slices; ++solutionIdx) + { + double val = arma::as_scalar(paretoSet.slice(solutionIdx)); + if (!IsInBounds(val, expectedLowerBound, expectedUpperBound)) + { + allInRange = false; + break; + } + } + + if (allInRange) + { + success = true; + break; + } + } + + REQUIRE(success == true); +} + +/** + * Optimize for the Fonseca Fleming function using NSGA-II optimizer. + * Tests for data of type double. + */ +TEST_CASE("MOEADFonsecaFlemingDoubleTest", "[MOEADTest]") +{ + FonsecaFlemingFunction FON; + const double lowerBound = -4; + const double upperBound = 4; + const double expectedLowerBound = -1.0 / sqrt(3); + const double expectedUpperBound = 1.0 / sqrt(3); + + MOEAD opt( + 150, // Population size. + 300, // Max generations. + 1.0, // Crossover probability. + 0.9, // Probability of sampling from neighbor. + 20, // Neighborhood size. + 20, // Perturbation index. + 0.5, // Differential weight. + 2, // Max childrens to replace parents. + 1E-10, // epsilon. + lowerBound, // Lower bound. + upperBound // Upper bound. + ); + typedef decltype(FON.objectiveA) ObjectiveTypeA; + typedef decltype(FON.objectiveB) ObjectiveTypeB; + + arma::mat coords = FON.GetInitialPoint(); + std::tuple objectives = FON.GetObjectives(); + + opt.Optimize(objectives, coords); + arma::cube paretoSet = opt.ParetoSet(); + + bool allInRange = true; + + for (size_t solutionIdx = 0; solutionIdx < paretoSet.n_slices; ++solutionIdx) + { + const arma::mat solution = paretoSet.slice(solutionIdx); + double valX = arma::as_scalar(solution(0)); + double valY = arma::as_scalar(solution(1)); + double valZ = arma::as_scalar(solution(2)); + + if (!IsInBounds(valX, expectedLowerBound, expectedUpperBound) || + !IsInBounds(valY, expectedLowerBound, expectedUpperBound) || + !IsInBounds(valZ, expectedLowerBound, expectedUpperBound)) + { + allInRange = false; + break; + } + } + + REQUIRE(allInRange); +} + +/** + * Optimize for the Fonseca Fleming function using NSGA-II optimizer. + * Tests for data of type double. + */ +TEST_CASE("MOEADFonsecaFlemingTestVectorDoubleBounds", "[MOEADTest]") +{ + FonsecaFlemingFunction FON; + const arma::vec lowerBound = {-4, -4, -4}; + const arma::vec upperBound = {4, 4, 4}; + const double expectedLowerBound = -1.0 / sqrt(3); + const double expectedUpperBound = 1.0 / sqrt(3); + + MOEAD opt( + 150, // Population size. + 300, // Max generations. + 1.0, // Crossover probability. + 0.9, // Probability of sampling from neighbor. + 20, // Neighborhood size. + 20, // Perturbation index. + 0.5, // Differential weight. + 2, // Max childrens to replace parents. + 1E-10, // epsilon. + lowerBound, // Lower bound. + upperBound // Upper bound. + ); + typedef decltype(FON.objectiveA) ObjectiveTypeA; + typedef decltype(FON.objectiveB) ObjectiveTypeB; + + arma::mat coords = FON.GetInitialPoint(); + std::tuple objectives = FON.GetObjectives(); + + opt.Optimize(objectives, coords); + arma::cube paretoSet = opt.ParetoSet(); + + bool allInRange = true; + + for (size_t solutionIdx = 0; solutionIdx < paretoSet.n_slices; ++solutionIdx) + { + const arma::mat solution = paretoSet.slice(solutionIdx); + double valX = arma::as_scalar(solution(0)); + double valY = arma::as_scalar(solution(1)); + double valZ = arma::as_scalar(solution(2)); + + if (!IsInBounds(valX, expectedLowerBound, expectedUpperBound) || + !IsInBounds(valY, expectedLowerBound, expectedUpperBound) || + !IsInBounds(valZ, expectedLowerBound, expectedUpperBound)) + { + allInRange = false; + break; + } + } + + REQUIRE(allInRange); +} + +/** + * Optimize for the Schaffer N.1 function using NSGA-II optimizer. + * Tests for data of type float. + */ +TEST_CASE("MOEADSchafferN1FloatTest", "[MOEADTest]") +{ + SchafferFunctionN1 SCH; + const double lowerBound = -1000; + const double upperBound = 1000; + const double expectedLowerBound = 0.0; + const double expectedUpperBound = 2.0; + + MOEAD opt( + 150, // Population size. + 300, // Max generations. + 1.0, // Crossover probability. + 0.9, // Probability of sampling from neighbor. + 20, // Neighborhood size. + 20, // Perturbation index. + 0.5, // Differential weight. + 2, // Max childrens to replace parents. + 1E-10, // epsilon. + lowerBound, // Lower bound. + upperBound // Upper bound. + ); + + typedef decltype(SCH.objectiveA) ObjectiveTypeA; + typedef decltype(SCH.objectiveB) ObjectiveTypeB; + + // We allow a few trials in case of poor convergence. + bool success = false; + for (size_t trial = 0; trial < 3; ++trial) + { + arma::fmat coords = SCH.GetInitialPoint(); + std::tuple objectives = SCH.GetObjectives(); + + opt.Optimize(objectives, coords); + arma::fcube paretoSet = arma::conv_to::from(opt.ParetoSet()); + + bool allInRange = true; + + for (size_t solutionIdx = 0; solutionIdx < paretoSet.n_slices; ++solutionIdx) + { + float val = arma::as_scalar(paretoSet.slice(solutionIdx)); + if (!IsInBounds(val, expectedLowerBound, expectedUpperBound)) + { + allInRange = false; + break; + } + } + + if (allInRange) + { + success = true; + break; + } + } + + REQUIRE(success == true); +} + +/** + * Optimize for the Schaffer N.1 function using NSGA-II optimizer. + * Tests for data of type float. + */ +TEST_CASE("MOEADSchafferN1TestVectorFloatBounds", "[MOEADTest]") +{ + // This test can be a little flaky, so we try it a few times. + SchafferFunctionN1 SCH; + const arma::vec lowerBound = {-1000}; + const arma::vec upperBound = {1000}; + const double expectedLowerBound = 0.0; + const double expectedUpperBound = 2.0; + + MOEAD opt( + 150, // Population size. + 300, // Max generations. + 1.0, // Crossover probability. + 0.9, // Probability of sampling from neighbor. + 20, // Neighborhood size. + 20, // Perturbation index. + 0.5, // Differential weight. + 2, // Max childrens to replace parents. + 1E-10, // epsilon. + lowerBound, // Lower bound. + upperBound // Upper bound. + ); + + typedef decltype(SCH.objectiveA) ObjectiveTypeA; + typedef decltype(SCH.objectiveB) ObjectiveTypeB; + + bool success = false; + for (size_t trial = 0; trial < 3; ++trial) + { + arma::fmat coords = SCH.GetInitialPoint(); + std::tuple objectives = SCH.GetObjectives(); + + opt.Optimize(objectives, coords); + arma::fcube paretoSet = arma::conv_to::from(opt.ParetoSet()); + + bool allInRange = true; + + for (size_t solutionIdx = 0; solutionIdx < paretoSet.n_slices; ++solutionIdx) + { + float val = arma::as_scalar(paretoSet.slice(solutionIdx)); + if (!IsInBounds(val, expectedLowerBound, expectedUpperBound)) + { + allInRange = false; + break; + } + } + + if (allInRange) + { + success = true; + break; + } + } + + REQUIRE(success == true); +} + +/** + * Optimize for the Fonseca Fleming function using NSGA-II optimizer. + * Tests for data of type float. + */ +TEST_CASE("MOEADFonsecaFlemingFloatTest", "[MOEADTest]") +{ + FonsecaFlemingFunction FON; + const double lowerBound = -4; + const double upperBound = 4; + const float expectedLowerBound = -1.0 / sqrt(3); + const float expectedUpperBound = 1.0 / sqrt(3); + + MOEAD opt( + 150, // Population size. + 300, // Max generations. + 1.0, // Crossover probability. + 0.9, // Probability of sampling from neighbor. + 20, // Neighborhood size. + 20, // Perturbation index. + 0.5, // Differential weight. + 2, // Max childrens to replace parents. + 1E-10, // epsilon. + lowerBound, // Lower bound. + upperBound // Upper bound. + ); + typedef decltype(FON.objectiveA) ObjectiveTypeA; + typedef decltype(FON.objectiveB) ObjectiveTypeB; + + arma::fmat coords = FON.GetInitialPoint(); + std::tuple objectives = FON.GetObjectives(); + + opt.Optimize(objectives, coords); + arma::fcube paretoSet = arma::conv_to::from(opt.ParetoSet()); + + bool allInRange = true; + + for (size_t solutionIdx = 0; solutionIdx < paretoSet.n_slices; ++solutionIdx) + { + const arma::fmat solution = paretoSet.slice(solutionIdx); + float valX = arma::as_scalar(solution(0)); + float valY = arma::as_scalar(solution(1)); + float valZ = arma::as_scalar(solution(2)); + + if (!IsInBounds(valX, expectedLowerBound, expectedUpperBound) || + !IsInBounds(valY, expectedLowerBound, expectedUpperBound) || + !IsInBounds(valZ, expectedLowerBound, expectedUpperBound)) + { + allInRange = false; + break; + } + } + + REQUIRE(allInRange); +} + +/** + * Optimize for the Fonseca Fleming function using NSGA-II optimizer. + * Tests for data of type float. + */ +TEST_CASE("MOEADFonsecaFlemingTestVectorFloatBounds", "[MOEADTest]") +{ + FonsecaFlemingFunction FON; + const arma::vec lowerBound = {-4, -4, -4}; + const arma::vec upperBound = {4, 4, 4}; + const float expectedLowerBound = -1.0 / sqrt(3); + const float expectedUpperBound = 1.0 / sqrt(3); + + MOEAD opt( + 150, // Population size. + 300, // Max generations. + 1.0, // Crossover probability. + 0.9, // Probability of sampling from neighbor. + 20, // Neighborhood size. + 20, // Perturbation index. + 0.5, // Differential weight. + 2, // Max childrens to replace parents. + 1E-10, // epsilon. + lowerBound, // Lower bound. + upperBound // Upper bound. + ); + typedef decltype(FON.objectiveA) ObjectiveTypeA; + typedef decltype(FON.objectiveB) ObjectiveTypeB; + + arma::fmat coords = FON.GetInitialPoint(); + std::tuple objectives = FON.GetObjectives(); + + opt.Optimize(objectives, coords); + arma::fcube paretoSet = arma::conv_to::from(opt.ParetoSet()); + + bool allInRange = true; + + for (size_t solutionIdx = 0; solutionIdx < paretoSet.n_slices; ++solutionIdx) + { + const arma::fmat solution = paretoSet.slice(solutionIdx); + float valX = arma::as_scalar(solution(0)); + float valY = arma::as_scalar(solution(1)); + float valZ = arma::as_scalar(solution(2)); + + if (!IsInBounds(valX, expectedLowerBound, expectedUpperBound) || + !IsInBounds(valY, expectedLowerBound, expectedUpperBound) || + !IsInBounds(valZ, expectedLowerBound, expectedUpperBound)) + { + allInRange = false; + break; + } + } + + REQUIRE(allInRange); +}