"""This module defines the Evolutionary Algorithm (EA) class.
This module defines the EA class, used to configure, create and train an
evolutionary algorithm. It defines the methods and processes currently
supported including:
* Evolution of a EA
* 4D visualisation of the evolution in the phenotype space.
Example:
>>> import loss_functions
>>> import test_functions
>>> from gene import Gene
>>> genotype = {
... "x": (float, [-100,100]),
... "y":(float, [-20,20]),
... }
>>> config = {
... "pop_size":100,
... "elitism":0.3,
... "indiv": genotype,
... "generations": 100,
... "crossover": True,
... "crossover rate": 0.3,
... "constraint":"Hard",
... "fitness": loss_functions.Minimize,
... "optimization function": test_functions.easom_test,
... "verbose" : True,
... "print rate" : 10,
... }
>>> ea = EA(config)
>>> ea.evolve() #doctest: +ELLIPSIS
----- Begin Evolution -----
...
----- Evolution Over -----
Best individual:
fitness : ...
Worst individual:
fitness : ...
>>> ea.visualise() #doctest: +ELLIPSIS
...
"""
import os
from typing import Dict, List, Callable, Any, Type
import numpy as np
from matplotlib import animation
from matplotlib import colors, cm
import matplotlib.pyplot as plt
from population import Population
Configuration = Dict[str, Any]
[docs]class EA:
"""The EA class, used to define an evolutionary process.
Defines an evolutionary process, performs the evolution of a population and
the visualisation of the process.
"""
def __init__(self, config:Configuration):
"""Inits an evolutionary process given a configuration."""
self.Population:Type[Population] = Population # pylint: disable=C0103
self.config:Configuration = config
self.Pop:Population = self.Population(self.config)# pylint: disable=C0103
self.Gen:int = self.config["generations"]# pylint: disable=C0103
self.evolution_process:List = []
self.verbose:bool = self.config["verbose"]
self.print_rate:int = self.config["print rate"]
if "optimization function" in config:
self.optimization_func:Callable = config["optimization function"]
else:
self.optimization_func:Callable = lambda x:x
self.fitness:Callable = config["fitness"]
if self.config["crossover"]:
self.crossover_rate:float = self.config["crossover rate"]
self.crossover:Callable = self._define_crossover()
else:
self.crossover:None = None
def _define_crossover(self) -> Callable:
"""Defines the crossover function."""
def crossover():
return self.Pop.crossover(self.crossover_rate)
return crossover
[docs] def evolve(self) -> None:
"""Evolves a population using the desired configuration.
Evolves a population of individuals with a given genotype through:
* Random mutations
* Crossovers (Optional)
* Elitism (Optional)
"""
print("----- Begin Evolution -----")
for gen in range(self.Gen):
if self.crossover is not None:
self.crossover()
self.Pop.evaluate()
if self.verbose is True:
if gen % self.print_rate == 0:
print(f"{self.Pop.scores()[-1]} < pop < {self.Pop.scores()[0]}")
self.evolution_process.append([
[v.value() for k,v in self.Pop.pop[0].genotype.items()]+
[self.Pop.pop[0].evaluate(self.optimization_func)],#, self.fitness)],
[v.value() for k,v in self.Pop.pop[-1].genotype.items()]+
[self.Pop.pop[-1].evaluate(self.optimization_func)],#, self.fitness)],
])
self.Pop.select()
print(("----- Evolution Over -----\n" +
"Best individual:\n" +
f" fitness : {self.Pop.scores()[0]}\n" +
self.Pop.pop[0].display())
)
print(("Worst individual:\n" +
f" fitness : {self.Pop.scores()[-1]}\n" +
self.Pop.pop[-1].display())
)
[docs] def visualise(self, savefig:str="animation") -> None:
"""Visualise the evolutionary process in 4D.
Makes a 4D (3d + time) visualisation of the evolutionary process by plotting
the phenotype space. Saves the animation in a folder named
``Visualisations``.
Args:
savefig (str): Name under which the animation will be saved.
"""
animation_directory = "Visualisations"
if not os.path.exists(animation_directory):
os.makedirs(animation_directory)
axes = []
for v in self.config["indiv"].values():
axes.append(np.linspace(v[1][0], v[1][1], 100))
if len(axes) > 2:
warning_str = ("This module can only visualise in 3D at the moment."
" Your genotype contains too many dimensions."
" Only the first two dimensions will be plotted.")
print(warning_str)
x, y = axes[0], axes[1]
x_array, y_array = np.meshgrid(x, y)
# z_array = self.fitness(self.optimization_func(x_array, y_array))
z_array = self.optimization_func(x_array, y_array)
z_min, z_max = np.array(z_array).min(), np.array(z_array).max()
z_min = min(np.array(self.evolution_process)[:,:,2].min(), z_min)
z_max = max(np.array(self.evolution_process)[:,:,2].max(), z_max)
if z_max-z_min > 10e2:
if z_min<0<z_max:
normalize = colors.SymLogNorm(
linthresh=10e-10,
base=np.e,
vmin=z_min,
vmax=z_max,
)
else:
normalize = colors.LogNorm(vmin=z_min, vmax=z_max)
else:
normalize = colors.Normalize(vmin=z_min, vmax=z_max)
fig, ax = plt.subplots(constrained_layout=True)
ax = plt.axes(projection="3d")
def animate(i):
ax.clear()
xmin = max(min(x)*(1-0.1), min(x) - 1)
xmax = max(max(x)*(1+0.1), max(x) + 1)
ymin = max(min(y)*(1-0.1), min(y) - 1)
ymax = max(max(y)*(1+0.1), max(y) + 1)
if z_min>=0:
zmin = (1-0.1)*z_min
else:
zmin = (1+0.1)* z_min
if z_max>=0:
zmax = (1-0.1)*z_max
else:
zmax = (1+0.1)*z_max
ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)
ax.set_zlim(zmin, zmax)
# Plot gen
ax.plot3D(
self.evolution_process[i][0][0],
self.evolution_process[i][0][1],
self.evolution_process[i][0][2],
marker="o", color="black",
)
ax.plot3D(
self.evolution_process[i][1][0],
self.evolution_process[i][1][1],
self.evolution_process[i][1][2],
marker="+", color="black",
)
ax.plot_surface(x_array, y_array, z_array, rstride=1, cstride=1,
cmap=cm.nipy_spectral, edgecolor="none", alpha=0.7, norm=normalize)
anim = animation.FuncAnimation(
fig, animate,
frames=len(self.evolution_process),
interval=5000, repeat=True,
)
anim.save(
f"{animation_directory}/{savefig}.gif",
writer="imagemagick", fps=60,
)
if __name__=="__main__":
import doctest
doctest.testmod(verbose=True, optionflags=doctest.ELLIPSIS)