Creating a Custom Controller
[2]:
from evokit.core import Population
from evokit.evolvables.binstring import BinaryString
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]']
When an BinaryString is created, it has yet to be evaluated. Attempting to access its .fitness
attribute raises an error.
[4]:
try:
pop[0].fitness
except ValueError as e:
print(f"An {type(e).__name__} is raised, with message \"{e}\"")
An ValueError is raised, with message "Score is accessed but null"
The selector only operates on individuals whose .fitness
is defined. Import an evaluator for BinaryString
to evaluate all items in pop
.
[5]:
from evokit.evolvables.binstring import CountBits
CountBits().evaluate_population(pop)
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
A selector selects from a Population
into a strict subset of it. For each item selected into this subset, that item is removed from the original Population
to prevent duplication of references.
This example uses a SimpleSelector
, which simply selects a number of individuals with the highest .fitness
. Set budget=3
to select 3 such individuals.
[6]:
from evokit.core import SimpleSelector
[7]:
sel = SimpleSelector(budget=3)
original_population = pop
parents = sel.select_to_population(original_population)
print(f"Original population has items {original_population}")
print(f"Output population has items {parents}")
Original population has items ['[1, 1, 0, 0, 0]', '[1, 0, 0, 0, 0]', '[0, 0, 0, 0, 0]']
Output population has items ['[1, 1, 1, 1, 1]', '[1, 1, 1, 1, 0]', '[1, 1, 1, 0, 0]']
A variator creates new individuals from existing individuals. Here, use MutateBits
which randomly flips each bit with probability mutation_rate
. Use 0.1 for now.
Apply the mutator to output_population to create a new population of offspring. Note that, unlike selectors, variators do not affect the Population
that is given argument.
Notice that not all items in the survivor pool are changed. With mutation_rate=0.1
, there is a probability of 0.6 that the variator does not affect an individual. The first and third individuals are duplicated exactly.
[8]:
import random
random.seed(24601)
from evokit.evolvables.binstring import MutateBits
variator = MutateBits(mutation_rate=0.1)
survivors = variator.vary_population(parents)
print (f"Parent: {parents}")
print (f"Offspring: {survivors}")
Parent: ['[1, 1, 1, 1, 1]', '[1, 1, 1, 1, 0]', '[1, 1, 1, 0, 0]']
Offspring: ['[1, 1, 1, 1, 1]', '[1, 1, 0, 1, 0]', '[1, 1, 1, 0, 0]']
Put everything together, define a Controller
that automates this process. The algorithm can take one
[9]:
from evokit.core import Algorithm
from typing import override
from evokit.core import Evaluator, Selector, Variator
class ExampleAlgorithm(Algorithm):
@override
def __init__(self,
population: Population[BinaryString],
evaluator: Evaluator[BinaryString],
selector: Selector[BinaryString],
variator: Variator[BinaryString]) -> None:
self.population = population
self.evaluator = evaluator
self.selector = selector
self.variator = variator
@override
def step(self) -> None:
self.population = self.variator.vary_population(self.population)
self.evaluator.evaluate_population(self.population)
self.population = \
self.selector.select_to_population(self.population)
This time, create random individuals by calling BinaryString.random
.
[10]:
another_pop = Population[BinaryString]()
for _ in range(5):
another_pop.append(BinaryString.random(size=5))
print(f"Initial population: {another_pop}")
ctrl = ExampleAlgorithm(another_pop,
CountBits(),
SimpleSelector(budget=3),
MutateBits(mutation_rate=0.1))
Initial population: ['[0, 0, 1, 1, 0]', '[0, 1, 1, 1, 0]', '[0, 0, 1, 0, 1]', '[0, 0, 0, 1, 0]', '[0, 1, 0, 1, 1]']
Run the algorithm. Observe that each iteration results in a new population. This is because ExampleAlgorithm
replaces the entirely of its population with the survivor pool. Doing so might, as is shown in the example, result in an decrease in fitness.
[11]:
for _ in range (10):
ctrl.step()
print(f"Current population: {ctrl.population}")
print(f"Current fitnesses: {[ind.fitness for ind in ctrl.population]}")
Current population: ['[1, 1, 0, 1, 1]', '[0, 0, 1, 1, 1]', '[0, 1, 1, 1, 0]']
Current fitnesses: [4, 3, 3]
Current population: ['[1, 1, 0, 1, 1]', '[0, 0, 1, 1, 1]', '[0, 1, 1, 1, 0]']
Current fitnesses: [4, 3, 3]
Current population: ['[1, 1, 0, 1, 1]', '[0, 0, 1, 1, 1]', '[0, 1, 1, 0, 0]']
Current fitnesses: [4, 3, 2]
Current population: ['[1, 1, 0, 1, 1]', '[0, 1, 1, 0, 0]', '[0, 0, 0, 1, 0]']
Current fitnesses: [4, 2, 1]
Current population: ['[1, 1, 0, 1, 1]', '[0, 1, 1, 0, 0]', '[0, 0, 0, 1, 0]']
Current fitnesses: [4, 2, 1]
Current population: ['[1, 1, 0, 1, 1]', '[0, 1, 1, 0, 0]', '[0, 0, 0, 1, 0]']
Current fitnesses: [4, 2, 1]
Current population: ['[1, 1, 0, 1, 1]', '[0, 1, 0, 0, 0]', '[0, 0, 0, 1, 0]']
Current fitnesses: [4, 1, 1]
Current population: ['[0, 1, 0, 1, 1]', '[1, 1, 0, 0, 0]', '[0, 0, 0, 0, 0]']
Current fitnesses: [3, 2, 0]
Current population: ['[0, 1, 0, 0, 1]', '[1, 1, 0, 0, 0]', '[0, 0, 0, 1, 0]']
Current fitnesses: [2, 2, 1]
Current population: ['[1, 1, 1, 0, 0]', '[0, 1, 0, 0, 0]', '[0, 0, 0, 1, 0]']
Current fitnesses: [3, 1, 1]