"""Real-valued (continuous) Genetic Algorithm implementation."""
import numpy as np
from numpy import ndarray
from .ga import GeneticAlgorithm
__all__ = ["RealValuedGeneticAlgorithm"]
[docs]
class RealValuedGeneticAlgorithm(GeneticAlgorithm):
"""
A real-valued (continuous) Genetic Algorithm (GA).
It finds minimums or maximums
of unconstrained problems over box-ranges of continuous variables, without
binary encoding.
Unlike :class:`~sigmaepsilon.math.optimize.bga.BinaryGeneticAlgorithm`, individuals
are represented directly as real-valued vectors (genotype == phenotype), using
arithmetic (blend) crossover and Gaussian mutation. This avoids the precision/length
trade-off inherent to binary encoding, at the cost of the different exploration
behavior of a continuous representation.
.. note::
This class is designed for maximizing the objective function. To minimize it, either negate
the objective function or pass ``minimize=True`` when instantiating the class.
Parameters
----------
fnc: Callable
The function to evaluate. It is assumed, that the function expects and N
number of scalar arguments as a 1d iterable.
ranges: Iterable
Ranges for each scalar argument to the objective function.
p_c: float, Optional
Probability of crossover. Default is 1.
p_m: float, Optional
Probability of mutation (per gene). Default is 0.2.
mutation_scale: float, Optional
Standard deviation of the Gaussian mutation noise, expressed as a fraction of
each variable's range. Default is 0.1.
nPop: int, Optional
The size of the population. Default is 100.
maxiter: int, Optional
The maximum number of iterations. Default is 200.
miniter: int, Optional
The minimum number of iterations. Default is 0.
elitism: float or int, Optional
Determines the portion of the population designated as elite, which automatically survives
to the next generation. If less than or equal to 1, it specifies a fraction of the population.
If greater than 1, it indicates the exact number of individuals to be selected as elite.
The default value of 1 assures that the reigning champion is always preserved. To turn this off,
set the value to None. Default is 1.
maxage: int, Optional
The age is the maximum number of generations a candidate spends at the top
(being the best candidate) before termination. Default is 5.
minimize: bool, Optional
If True, the objective function is minimized. Default is False.
seed: int | numpy.random.SeedSequence | numpy.random.Generator | None, Optional
A seed for a per-instance random number generator. Default is None.
selection_strategy: :class:`~sigmaepsilon.math.optimize.selection.SelectionStrategy`, Optional
The selection strategy used by :func:`select`. Default is
:class:`~sigmaepsilon.math.optimize.selection.TournamentSelection`.
vectorized: bool, Optional
See :func:`~sigmaepsilon.math.optimize.ga.GeneticAlgorithm.evaluate`. Default is False.
n_jobs: int, Optional
See :func:`~sigmaepsilon.math.optimize.ga.GeneticAlgorithm.evaluate`. Default is 1.
See Also
--------
:class:`~sigmaepsilon.math.optimize.bga.BinaryGeneticAlgorithm`
:class:`~sigmaepsilon.math.optimize.iga.IntegerGeneticAlgorithm`
Examples
--------
Find the minimizer of the Rosenbrock function.
The exact value of the solution is x = [1.0, 1.0].
>>> from sigmaepsilon.math.optimize.rga import RealValuedGeneticAlgorithm as RGA
>>>
>>> def rosenbrock(x):
... a, b = 1, 100
... return (a-x[0])**2 + b*(x[1]-x[0]**2)**2
>>>
>>>
>>> ranges = [[-10, 10], [-10, 10]]
>>> rga = RGA(rosenbrock, ranges, nPop=100, minimize=True)
>>> _ = rga.solve()
>>> champion = rga.champion
>>> x = champion.phenotype
>>> fx = champion.fitness
"""
__slots__ = ("mutation_scale",)
def __init__(self, *args, mutation_scale: float = 0.1, **kwargs):
self.mutation_scale = mutation_scale
super().__init__(*args, **kwargs)
def populate(self, genotypes: ndarray | None = None) -> ndarray:
"""Populate the model and return the array of genotypes."""
nPop = self.nPop
if genotypes is None:
low = self.ranges[:, 0]
high = self.ranges[:, 1]
genotypes = self.rng.uniform(low, high, size=(int(nPop / 2), self.dim))
nParent = genotypes.shape[0]
if nParent < nPop:
offspring = []
g = self.random_parents_generator(genotypes)
try:
while (len(offspring) + nParent) < nPop:
parent1, parent2 = next(g)
offspring.extend(self.crossover(parent1, parent2))
genotypes = np.vstack([genotypes, offspring])
except Exception: # pragma: no cover
raise RuntimeError
return genotypes
def crossover(self, parent1: ndarray, parent2: ndarray) -> tuple[ndarray, ndarray]:
"""Perform arithmetic (blend) crossover on the parents.
Crosses `parent1` and `parent2` and returns two children.
"""
if self.rng.random() > self.p_c: # pragma: no cover
return parent1, parent2
alpha = self.rng.random(self.dim)
child1 = alpha * parent1 + (1 - alpha) * parent2
child2 = alpha * parent2 + (1 - alpha) * parent1
return self.mutate(child1), self.mutate(child2)
def mutate(self, child: ndarray) -> ndarray:
"""Return a mutated genotype. Children come in, mutants go out."""
mask = self.rng.random(self.dim) < self.p_m
if not np.any(mask):
return child
span = self.ranges[:, 1] - self.ranges[:, 0]
noise = self.rng.normal(scale=self.mutation_scale * span, size=self.dim)
mutant = np.where(mask, child + noise, child)
return np.clip(mutant, self.ranges[:, 0], self.ranges[:, 1])