Custom Components#

We saw in the quick start how to implement algorithms using wrappers around plain functions. In this tutorial, we are going to see what is needed to implement each of the components as standalone classes so we can implement more complex logic and make each component hold internal state.

Throughout this guide the following type aliases are used for clarity:

  • MatrixLike – a 2‑D NumPy array (n_individuals × n_vars).

  • VectorLike – a 1‑D NumPy array (fitness values).

  • RNGLike – a NumPy Generator or a seed.

All examples assume maximization (higher fitness is better); if your problem minimizes, set mode="min" in the objective function.

Random Number Generators#

In this library, reproducibility is enforced by passing a numpy Generator (numpy.random.Generator subclass) instance to each function that needs to generate random numbers. Instead of creating a generator from scratch, we allow the use of seeds as integers. If you want to allow this functionality in your custom implementations we provide a check_rng utility that normalizes the random generator object.

This function accepts either a numerical seed like 42, a random Generator or None and returns a properly initialized Generator. Note that when None is passed, a new completely random generator is produced that will use an arbitrary seed.

import numpy as np
from metaheuristic_designer.utils import check_rng

# Completely random Generator
rng = check_rng(None)

# Generator with a seed
rng = check_rng(42)

# Generator from a numpy generator
random_generator = np.random.default_rng(42)
rng = check_rng(random_generator)

Parameter Schedules#

Many interfaces used in this library implement the ParametrizableMixin. in particular, these are every base class that implements this mixin:

Any of these classes accept keyword arguments with any value or an implementation of SchedulableParameter. To access these parameters, we can access the params attribute that will hold the concrete value of the parameter. Let’s see an example.

First, we’ll create a custom SchedulableParameter by sub-classing it and implementing the evaluate method, which gets a progress value between 0 and 1, and returns the value of the parameter.

from metaheuristic_designer import SchedulableParameter

class ProgressProportionalSchedule(SchedulableParameter):
    def __init__(self, value):
        super().__init__()
        self.value = value

    def evaluate(self, progress):
        return self.value * progress

With this new parameter schedule, we can use it as a key-word argument in any of the corresponding class and it will become a parameter that depends on the progress value of the algorithm. As an example, we can have a custom class that implement ParametrizableMixin. And we can show how to work with its parameters. We also note that the update function is called automatically each iteration in the inner loop of the algorithm. Parameters are initialized to a progress value of 0.

from metaheuristic_designer import ParametrizableMixin

class CustomClass(ParametrizableMixin):
    def __init__(self, a, b):
        super().__init__()
        self.store_kwargs(a=a, b=b)

b_sched = ProgressProportionalSchedule(value=10)
c = CustomClass(a = 1, b = b_sched)

print(c.params.a, c.params.b) # Prints: 1, 0
c.update(progress=0.5)
print(c.params.a, c.params.b) # Prints: 1, 5

In every class that already implements this mixin, the store_kwargs call is done with the remaining kwargs not used by the class. The parameters can be also manually reset in runtime with the update_kwargs method.

c = CustomClass(a = 1, b = 4)
print(c.params.a, c.params.b) # Prints: 1, 4

c.update_kwargs(a = 2, b = ProgressProportionalSchedule(value=6))
c.update(progress=1)
print(c.params.a, c.params.b) # Prints: 2, 6

There are also some available schedules that take other schedules as input and modify them in different ways, they are listed in the api_reference page.

Objective Function#

To implement an objective function from the abstract ObjectiveFunc class, we will make a new class inheriting from the Interface. The only mandatory attribute to indicate is the dimension and we will need to provide an implementation of the objective function. When using this implementation, you are also allowed to specify a name by the name attribute.

The objective function will take as input a decoded solution (with the type outputted by the corresponding decoder method in the Encoding) and will output a single float number. Once this method is implemented, the base class provides the calculate_fitness method which will evaluate the solutions present in a Population object using the specified objective.

Note

By default, objective functions are not vectorized, which means that the objective is computed individually for each solution. If possible, it is recommended that the attribute vectorized is set to True and the objective is implemented by taking a matrix of solutions and outputting a vector of objective values.

Here is an example of a concrete ObjectiveFunc.

import numpy as np
from metaheuristic_designer import ObjectiveFunc, Population
from metaheuristic_designer.utils import check_rng

class GaussianPeak(ObjectiveFunc):
    def __init__(self, dimension: int):
        super().__init__(dimension=dimension, name="Gaussian Peak")

    def objective(self, solution):
        objective_value = np.exp(-np.sum(solution ** 2))
        return objective_value

objfunc = GaussianPeak(2)
population = Population(np.random.uniform(0, 1, (3, 2)))
population = objfunc.calculate_fitness(population) # Compute fitness vector inside the population
print(population.objective)

We should note that functions are maximized by default, if we want to minimize the function, we can specify that the mode attribute is set to "min". We can also implement the function in vectorized form.

import numpy as np
from metaheuristic_designer import ObjectiveFunc, Population
from metaheuristic_designer.utils import check_rng

class NoisySphereVectorized(ObjectiveFunc):
    def __init__(self, dimension, sigma=1e-5, rng=None):
        super().__init__(dimension=dimension, vectorized=True, name="Min Noisy Sphere", mode="min")
        self.rng = check_rng(rng)
        self.sigma = sigma

    def objective(self, solution):
        objective_vector = np.sum(solution ** 2, axis=1)
        noise_vector = self.rng.normal(0, self.sigma, size=solution.shape[0])
        return objective_vector + noise_vector

objfunc = NoisySphereVectorized(5)
population = Population(np.random.uniform(0, 1, (3, 5)))
population = objfunc.calculate_fitness(population) # Compute fitness vector inside the population
print(population.objective)

Constraint Handler#

Constraint Handlers are objects that are meant to be embedded into an objective function and work by enforcing the constraints of the problem with two different mechanisms, penalties and solution repairing.

Both mechanisms are mutually exclusive, repairing a solution means penalties will be always 0, so we cannot have both at the same time. For this purpose, we will have two different interfaces to implement ConstraintHandler objects.

If we want to create a repairing procedure, we will make use of the RepairConstraint abstract class. This way, we will be implementing a repair_solutions method that gets a matrix representing the solutions of our problem and must return an identically sized matrix of repaired solutions.

import numpy as np
from metaheuristic_designer import RepairConstraint

class NormalizeRepairing(RepairConstraint):
    def __init__(self, norm=1):
        super().__init__()
        self.norm = norm

    def repair_solutions(self, solutions):
        current_norm = np.linalg.norm(solutions, axis=1)

        return (self.norm/current_norm) * solutions

Alternatively, if we want to specify a penalty method, we will use the PenalizeConstraint abstract class. We will only need to implement the penalty method which gets an collection of solutions and must return a vector containing the penalty value for each of the solutions.

import numpy as np
from metaheuristic_designer import PenalizeConstraint

class NormPenaltyConstraint(PenalizeConstraint):
    def __init__(self, max_norm=1):
        # The base class stores keyword arguments in self.params
        super().__init__(max_norm=max_norm)

    def penalty(self, solutions):
        penalties = np.zeros(len(solutions))

        for idx, s in enumerate(solutions):
            s_norm = np.linalg.norm(s)
            if s_norm > self.params.max_norm:
                penalties[idx] = s_norm - 1

        return penalties

You are allowed to chain repairing methods or add the penalty values by using the CompositeConstraint class.

Encoding#

If we need a custom encoding, we can subclass the interface Encoding, this new class should implement both an encode and decode function. The encode function will take a collection of solutions in their natural representation, and will return a numpy matrix with size (NxM) where N is the number of solutions and M is the number of components of each solution. The decode function will take that matrix with the internal solution representation and return a collection of solutions in their natural representation. Sometimes the decode function is not completely reversible, in this case, it is completely acceptable to make the encode function return a sentinel value, since it will very rarely be used in the actual optimization procedure.

import numpy as np
from metaheuristic_designer import Encoding

class MulticategoricalEncoding(Encoding):
    def __init__(self, categories: list):
        self.categories = categories
        self.n_vars = len(categories)
        super().__init__()

    def encode(self, solutions):
        N = len(solutions)
        encoded = np.empty((N, self.n_vars), dtype=int)
        for i, sol in enumerate(solutions):
            for j, val in enumerate(sol):
                encoded[i, j] = self.categories[j].index(val)
        return encoded

    def decode(self, population_matrix):
        solutions = []
        for i in range(population_matrix.shape[0]):
            sol = tuple(self.categories[j][idx] for j, idx in enumerate(population_matrix[i]))
            solutions.append(sol)
        return solutions

enc = MulticategoricalEncoding(
    [('red', 'blue', 'green'), ('small', 'large'), ('A', 'B')]
)

solutions = [('red', 'small', 'B'), ('green', 'large', 'A')]

population_matrix = enc.encode(solutions)
print(population_matrix)   # [[0 0 1]
                           #  [2 1 0]]

decoded = enc.decode(population_matrix)
print(decoded)   # [('red', 'small', 'B'), ('green', 'large', 'A')]

You are allowed to chain encodings with the CompositeEncoding class. It is important to note that the decoding will be done in the same order as the provided list of encodings, and the encoding will be done in reverse order.

Initializer#

When creating an initializer, we will make use of the Initializer class. To implement a concrete class, we will have to implement at least the generate_random method, that will generate a single random vector. We should pass a dimension to the parent constructor and accept a population_size argument as well. Initializers have an integrated rng handler, so we should pass it as an argument. We will also handle the encoding passing through the initializer to ensure the population is generated with the correct encoding.

from metaheuristic_designer import Initializer, Encoding

class CategoricalInitializer(Initializer):
    def __init__(self, dimension: int, values: list, population_size: int, encoding: Encoding = None, rng = None):
        super().__init__(dimension=dimension, population_size=population_size, encoding=encoding, rng=rng)

        self.values = values

    def generate_random(self):
        return self.rng.choice(self.values, self.dimension)

initializer = CategoricalInitializer(dimension=5, values=(0, 4, 10), population_size=3)
new_population = initializer.generate_population()
print(new_population)

new_population = initializer.generate_population(10)
print(new_population)

If we are intending to make a deterministic initializer, it is recommended to use a fallback uniform initializer, and implement the actual logic in the generate_individual method.

import numpy as np
from metaheuristic_designer import Initializer, Encoding
from metaheuristic_designer.initializers import UniformInitializer

class ConstantInitializer(Initializer):
    def __init__(self, dimension: int, value: float, population_size: int, fallback: Initializer, encoding: Encoding = None, rng = None):
        super().__init__(dimension=dimension, population_size=population_size, encoding=encoding, rng=rng)

        self.value = value
        self.fallback = fallback

    def generate_random(self):
        return self.fallback.generate_random()

    def generate_individual(self):
        return np.full(self.dimension, self.value)

fallback_init = UniformInitializer(dimension=5, lower_bound=0, upper_bound=10, dtype=int)
initializer = ConstantInitializer(dimension=5, value=4, population_size=3, fallback=fallback_init)
print(initializer.generate_random())

new_population = initializer.generate_population()
print(new_population)

Sometimes, an initializer will need to generate the entire population in one go. For this purpose, we will override the generate_population method, which will be given a number of individuals to generate n_individuals that will often fall back to the specified population size.

import numpy as np
from metaheuristic_designer import Initializer, Encoding, Population
from metaheuristic_designer.initializers import UniformInitializer

class IdentityMatrixInitializer(Initializer):
    def __init__(self, dimension: int, fallback: Initializer, encoding: Encoding = None, rng = None):
        super().__init__(dimension=dimension, population_size=dimension, encoding=encoding, rng=rng)
        self.fallback = fallback

    def generate_random(self):
        return self.fallback.generate_random()

    def generate_population(self, n_individuals = None):
        if n_individuals is None:
            n_individuals = self.population_size

        assert n_individuals <= self.dimension, f"Can only generate up to {self.dimension} individuals"

        id_matrix = np.eye(self.dimension)[:n_individuals]
        return Population(id_matrix, encoding=self.encoding)

fallback_init = UniformInitializer(dimension=5, lower_bound=0, upper_bound=10, dtype=int)
initializer = IdentityMatrixInitializer(dimension=5, fallback=fallback_init)

new_population = initializer.generate_population()
print(new_population)

You are allowed to combine initializers with the CompositeInitializer class, so that individuals will be generated at random with any of the specified initializers. Alternatively, there also is a FixedCompositeInitializer that chooses individuals in a deterministic manner.

Operators#

If we want to create a custom operator, we can subclass the Operator, implementing the evolve method, which gets a Population instance, and returns a new Population with the new solutions.

from metaheuristic_designer import Operator, Population

class ModularAdditionMutation(Operator):
    def __init__(self, value: int, mod: int):
        super().__init__(name="Modular addition", preserves_order=True, value=value)
        self.mod = mod

    def evolve(self, population: Population):
        new_matrix = (population.genotype_matrix + self.params.value) % self.mod
        new_population = population.update_genotype(new_matrix)
        return new_population

op = ModularAdditionMutation(value = 1, mod = 7)
population = Population(np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]))

new_population = op.evolve(population)
print(new_population.genotype_matrix)

There are a number of different ways to combine operators, we recommend checking out the methods page to see all the available composition strategies.

Parent Selection#

For creating custom Parent Selection strategies, we can subclass ParentSelection and implement the select method, which takes as input a Population and returns a new population of selected individuals. It is highly recommended to set the last_selection_idx attribute while performing the selection, as it can be of use for other methods, it should be a vector contain only indices of the selected individuals.

import numpy as np
from metaheuristic_designer import ParentSelection, Population

class TruncateSelection(ParentSelection):
    def __init__(self, amount: int):
        super().__init__(amount=amount)

    def select(self, population: Population, amount: int = None):
        if amount is None:
            amount = self.params.amount

        n_individuals = population.population_size

        self.last_selection_idx = np.arange(n_individuals)[:amount]
        return population.take_selection(self.last_selection_idx)

parent_sel = TruncateSelection(amount=2)
population = Population(np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]))

new_population = parent_sel.select(population)
print(new_population.genotype_matrix)

Survivor Selection#

For creating custom Survivor Selection strategies, we can subclass SurvivorSelection and implement the select method, which takes as input two Population instances and returns a new population of selected individuals. It is highly recommended to set the last_selection_idx attribute while performing the selection as with parent selection methods, as it can be of use for other methods, it should be a vector contain only indices of the selected individuals. If the first population has size N and the second has size M, the valid indices are those between 0 and N+M-1, where the first N indices indicate individuals from the first population and indices between N and N+M-1 indicate individuals from the second.

import numpy as np
from metaheuristic_designer import SurvivorSelection, Population

class FullConcatenationSelection(SurvivorSelection):
    def __init__(self, amount: int):
        super().__init__()

    def select(self, parents: Population, offspring: population):
        n_parents = parents.population_size
        n_offspring = offspring.population_size

        self.last_selection_idx = np.arange(n_parents+n_offspring)
        return Population.join_populations(parents, offspring)

parent_sel = FullConcatenationSelection()
parents = Population(np.array([[1, 2, 3]]))
offspring = Population(np.array([[4, 5, 6]]))

new_population = parent_sel.select(parents, offspring)
print(new_population.genotype_matrix)

Search strategy#

If we want to design a new Search strategy, the recommended approach is to implement the necessary components and pass them to an already existing search strategy blueprint. In the vast majority of cases, it is enough to create a PopulationBasedStrategy or a SingleSolutionStrategy if we work with a single solution. Here we show an example.

from metaheuristic_designer.benchmarks import Sphere
from metaheuristic_designer.initializers import UniformInitializer
from metaheuristic_designer.parent_selection import create_parent_selection
from metaheuristic_designer.operators import create_operator
from metaheuristic_designer.survivor_selection import create_survivor_selection
from metaheuristic_designer.strategies import PopulationBasedStrategy

DIM = 5

objfunc = Sphere(DIM)
init = UniformInitializer(DIM, -10, 10, population_size=100, rng=42)
parent_sel = create_parent_selection("elitist", amount=50, rng=42)
operator = create_operator("mutation.gaussian_mutation", rng=42)
survivor_sel = create_survivor_selection("keep_best", rng=42)

search_strategy = PopulationBasedStrategy(
    initializer=init,
    parent_sel=parent_sel,
    operator=operator,
    survivor_sel=survivor_sel,
    rng=42
)

If the options given are not enough, you can still subclass the SearchStrategy class and implement the step function that takes a Population and an ObjectiveFunc instance, and returns a new population. You should be very careful to add as little logic as possible into Search strategies since they are meant to only be an orchestrator class that passes the population object between components. The reason behind this is to encourage the use of smaller components that can be fit into different algorithms, which would not be possible if the logic was hard-coded into a Search Strategy class.

Instead of creating a new search strategy, since we highly discourage creating new strategies, we show the exact implementation of the PopulationBasedStrategy class so the structure is clear in case it’s needed.

from copy import copy

from metaheuristic_designer.population import Population
from metaheuristic_designer.objective_function import ObjectiveFunc
from metaheuristic_designer.initializer import Initializer
from metaheuristic_designer.parent_selection_base import ParentSelection
from metaheuristic_designer.survivor_selection_base import SurvivorSelection
from metaheuristic_designer.search_strategy import SearchStrategy
from metaheuristic_designer.operator import Operator

class PopulationBasedStrategy(SearchStrategy):
    def __init__(
        self,
        initializer: Initializer,
        operator: Operator = None,
        parent_sel: ParentSelection = None,
        survivor_sel: SurvivorSelection = None,
        name: str = "Static Population Evolution",
        rng = None,
        **kwargs,
    ):
        super().__init__(
            initializer=initializer,
            operator=operator,
            parent_sel=parent_sel,
            survivor_sel=survivor_sel,
            name=name,
            rng=rng,
            **kwargs
        )

    def step(self, prev_population: Population, objfunc: ObjectiveFunc) -> Population:
        population = self.parent_sel.select(prev_population)
        population = self.operator.evolve(population)
        population = objfunc.repair_population(population)
        population = objfunc.calculate_fitness(population)
        population = self.survivor_sel.select(population=prev_population, offspring=population)
        return population

Reporter#

To implement a custom Reporter class, we will subclassing and implementing the three methods log_init that will display information at the start of the algorithm, log_step that displays information each iteration of the algorithm and log_end that is called at the end of the optimization. Every method gets an Algorithm instance and has no return value.

from metaheuristic_designer.benchmarks import Sphere
from metaheuristic_designer.simple import evolution_strategy_real
from metaheuristic_designer import Reporter

class SimpleReporter(Reporter):
    def __init__(self):
        self.iterations = 0

    def log_init(self, alg):
        print("Starting algorithm")

    def log_step(self, alg):
        self.iterations += 1
        print(f"Iteration {self.iterations}.")

    def log_end(self, alg):
        print(f"Ran for {self.iterations} iterations")

objfunc = Sphere(5)
reporter = SimpleReporter()
alg = evolution_strategy_real(
    objfunc,
    max_iterations=100,
    stop_condition_str="max_iterations",
    reporter=reporter
)
alg.optimize() # Shows the reporting prints

An interesting detail is that reporters are not forced to only write on the command line and, theoretically, it is possible to let reporters output in any format you can think of, including live-updating plots or writing to files.