Creating a Custom Selector
In an evolutionary algorithm, a selector selects from a set of individuals into a strict subset. The following diagram illustrates where selection occurs in a typical evolutionary algorithm:
.
[2]:
from evokit.core import Population
from evokit.evolvables.binstring import BinaryString
To use a selector, first create a Population
. This example uses the binary string representation.
[3]:
pop : Population[BinaryString] = Population[BinaryString]()
pop.append(BinaryString(int('11111', 2), 5))
pop.append(BinaryString(int('11110', 2), 5))
pop.append(BinaryString(int('11100', 2), 5))
pop.append(BinaryString(int('11000', 2), 5))
pop.append(BinaryString(int('10000', 2), 5))
pop.append(BinaryString(int('00000', 2), 5))
print(pop)
['[1, 1, 1, 1, 1]', '[1, 1, 1, 1, 0]', '[1, 1, 1, 0, 0]', '[1, 1, 0, 0, 0]', '[1, 0, 0, 0, 0]', '[0, 0, 0, 0, 0]']
To make things easier, manually assign a .fitness
to each item in the population. This is normally done by a pre-defined Evaluator
.
[4]:
pop[0].fitness = 5
pop[1].fitness = 4
pop[2].fitness = 3
pop[3].fitness = 2
pop[4].fitness = 1
pop[5].fitness = 0
Verify that all individuals are correctly evaluated:
[5]:
for individual in pop:
print(f"Fitness of {individual} is {individual.fitness}")
Fitness of [1, 1, 1, 1, 1] is 5
Fitness of [1, 1, 1, 1, 0] is 4
Fitness of [1, 1, 1, 0, 0] is 3
Fitness of [1, 1, 0, 0, 0] is 2
Fitness of [1, 0, 0, 0, 0] is 1
Fitness of [0, 0, 0, 0, 0] is 0
Selector
The abstract class Selector
implements three methods:
.select
defines the strategy for a single selection operation. It has no default implementation..select_to_many
applies the.select
to a collection of individuals. It also handles the removal of selected individuals from the original population. The default implementation repeatedly applies .select_to_many, until.budget
items are selected..select_to_population
applies the.select_to_many
to a population.
In general, a custom selector must override .select
. A selector that requires information from the entire population (e.g. a fitness sharing selector) may override .select_to_many
. Do not override .select_to_population
- define a new method that uses .select_to_many
to select from something else.
class Selector(ABC, Generic[D]):
def __init__(self: Self, budget: int): ...
def select_to_population(self,
population: Population[D]) -> Population[D]: ...
def select_to_many(self, population: Population[D]) -> Tuple[D, ...]:
...
@abstractmethod
def select(self,
population: Population[D]) -> Tuple[D, ...]: ...
Define a custom selector. Because the implementation does not provide its own way to decide how many items to select, it must have the .budget
attribute. There are many ways to do it; this example uses the super constructor.
The .select
method returns a tuple of items from a population. Because the ExampleSimpleSelector
only selects the best individual, .select
should return a 1-tuple.
[6]:
from typing import override, Self, Tuple
from evokit.core import Selector
class ExampleSimpleSelector(Selector[BinaryString]):
"""Simple selector that select the highest-fitness individual.
Example for overriding `select`.
"""
@override
def __init__(self: Self):
super().__init__(3)
def select(self,
population: Population[BinaryString]) -> Tuple[BinaryString]:
population.sort(lambda x: x.fitness)
selected_solution = population[0]
return (selected_solution,)
Apply the selector to the population. Because the selected individual is not removed from the original population, the selector always chooses [1,1,1,1,1]
- the individual with the highest fitness.
[7]:
old_pop = pop
selector = ExampleSimpleSelector()
for _ in range(3):
selected_items = selector.select(old_pop)
print(f"Selected items are {[str(x) for x in selected_items]}")
print(f"After selection, the old population is {old_pop}")
Selected items are ['[1, 1, 1, 1, 1]']
Selected items are ['[1, 1, 1, 1, 1]']
Selected items are ['[1, 1, 1, 1, 1]']
After selection, the old population is ['[1, 1, 1, 1, 1]', '[1, 1, 1, 1, 0]', '[1, 1, 1, 0, 0]', '[1, 1, 0, 0, 0]', '[1, 0, 0, 0, 0]', '[0, 0, 0, 0, 0]']
Observe the effect of select_to_many. With .budget=3
, .select_to_many
returns a 3-tuple. Note that items in the returned tuple are removed from the original population.
Calling .select_to_population
produces the same result, except that it returns a population with 3 items.
[8]:
old_pop = pop
selector = ExampleSimpleSelector()
selected_items = selector.select_to_many(old_pop)
print(f"Selected items are {[str(x) for x in selected_items]}")
print(f"After selection, the old population is {old_pop}")
Selected items are ['[1, 1, 1, 1, 1]', '[1, 1, 1, 1, 0]', '[1, 1, 1, 0, 0]']
After selection, the old population is ['[1, 1, 0, 0, 0]', '[1, 0, 0, 0, 0]', '[0, 0, 0, 0, 0]']