Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add GeneticAlgorithmEmitter with Internal Operator Support #427

Merged
merged 10 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

#### API

- Add GeneticAlgorithmEmitter with Internal Operator Support ({pr} `427`)
- Support custom data fields in archive ({pr}`421`)
- **Backwards-incompatible:** Remove `_batch` from parameter names ({pr}`422`,
{pr}`424`, {pr}`425`)
Expand Down
2 changes: 2 additions & 0 deletions ribs/emitters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from ribs.emitters._emitter_base import EmitterBase
from ribs.emitters._evolution_strategy_emitter import EvolutionStrategyEmitter
from ribs.emitters._gaussian_emitter import GaussianEmitter
from ribs.emitters._genetic_algorithm_emitter import GeneticAlgorithmEmitter
from ribs.emitters._gradient_arborescence_emitter import \
GradientArborescenceEmitter
from ribs.emitters._gradient_operator_emitter import GradientOperatorEmitter
Expand All @@ -37,4 +38,5 @@
"GradientOperatorEmitter",
"IsoLineEmitter",
"EmitterBase",
"GeneticAlgorithmEmitter",
svott03 marked this conversation as resolved.
Show resolved Hide resolved
]
183 changes: 183 additions & 0 deletions ribs/emitters/_genetic_algorithm_emitter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Provides the GeneticAlgorithmEmitter."""
import numpy as np

from ribs._utils import check_batch_shape, check_shape
from ribs.emitters._emitter_base import EmitterBase
from ribs.emitters.operators import _get_op


class GeneticAlgorithmEmitter(EmitterBase):
"""Emits solutions by using operator provided
svott03 marked this conversation as resolved.
Show resolved Hide resolved

If the archive is empty and ``self._initial_solutions`` is set, a call to
:meth:`ask` will return ``self._initial_solutions``. If
``self._initial_solutions`` is not set, we operate on self.x0.


Args:
archive (ribs.archives.ArchiveBase): An archive to use when creating and
inserting solutions. For instance, this can be
:class:`ribs.archives.GridArchive`.
x0 (np.ndarray): Initial solution.
svott03 marked this conversation as resolved.
Show resolved Hide resolved
operator (external class): Operator Class from pymoo or pygad
svott03 marked this conversation as resolved.
Show resolved Hide resolved
os (string): External Library identifier
svott03 marked this conversation as resolved.
Show resolved Hide resolved
initial_solutions (array-like): An (n, solution_dim) array of solutions
to be used when the archive is empty. If this argument is None, then
solutions will be sampled from a Gaussian distribution centered at
``x0`` with standard deviation ``sigma``.
bounds (None or array-like): Bounds of the solution space. Solutions are
clipped to these bounds. Pass None to indicate there are no bounds.
Alternatively, pass an array-like to specify the bounds for each
dim. Each element in this array-like can be None to indicate no
bound, or a tuple of ``(lower_bound, upper_bound)``, where
``lower_bound`` or ``upper_bound`` may be None to indicate no bound.
batch_size (int): Number of solutions to return in :meth:`ask`.
seed (int): Value to seed the random number generator. Set to None to
avoid a fixed seed.
Raises:
ValueError: There is an error in x0 or initial_solutions.
ValueError: There is an error in the bounds configuration.
"""

def __init__(self,
archive,
*,
x0=None,
initial_solutions=None,
bounds=None,
batch_size=64,
os=None,
seed=None,
iso_sigma=0.01,
svott03 marked this conversation as resolved.
Show resolved Hide resolved
line_sigma=0.2,
sigma=0.1):
self._batch_size = batch_size
self._os = os
self._x0 = x0
self._initial_solutions = None
self._seed = seed
self._sigma = sigma
self._iso_sigma = iso_sigma
self._line_sigma = line_sigma
self._rng = np.random.default_rng(seed)

if x0 is None and initial_solutions is None:
raise ValueError("Either x0 or initial_solutions must be provided.")
if x0 is not None and initial_solutions is not None:
raise ValueError(
"x0 and initial_solutions cannot both be provided.")

if x0 is not None:
self._x0 = np.array(x0, dtype=archive.dtype)
check_shape(self._x0, "x0", archive.solution_dim,
"archive.solution_dim")
elif initial_solutions is not None:
self._initial_solutions = np.asarray(initial_solutions,
dtype=archive.dtype)
check_batch_shape(self._initial_solutions, "initial_solutions",
archive.solution_dim, "archive.solution_dim")

EmitterBase.__init__(
svott03 marked this conversation as resolved.
Show resolved Hide resolved
self,
archive,
solution_dim=archive.solution_dim,
bounds=bounds,
)
if self._os == 'isoline':
self._operator = _get_op(os)(lower_bounds=self._lower_bounds,
upper_bounds=self._upper_bounds,
seed=self._seed,
iso_sigma=self._iso_sigma,
line_sigma=self._line_sigma)
elif self._os == 'gaussian':
self._operator = _get_op(os)(lower_bounds=self._lower_bounds,
upper_bounds=self._upper_bounds,
seed=self._seed,
sigma=self._sigma)
else:
raise ValueError(f"{self._os} not a supported operator.")

@property
def x0(self):
"""numpy.ndarray: Initial Solution (if initial_solutions is not
set)."""
return self._x0

@property
svott03 marked this conversation as resolved.
Show resolved Hide resolved
def iso_sigma(self):
"""float: Scale factor for the isotropic distribution used to
generate solutions when the archive is not empty."""
return self._iso_sigma

@property
def line_sigma(self):
"""float: Scale factor for the line distribution used when generating
solutions."""
return self._line_sigma

@property
def sigma(self):
"""float or numpy.ndarray: Standard deviation of the (diagonal) Gaussian
distribution when the archive is not empty."""
return self._sigma

@property
def initial_solutions(self):
"""numpy.ndarray: The initial solutions which are returned when the
archive is empty (if x0 is not set)."""
return self._initial_solutions

@property
def batch_size(self):
"""int: Number of solutions to return in :meth:`ask`."""
return self._batch_size

def ask(self):
"""Creates solutions with operator provided.

svott03 marked this conversation as resolved.
Show resolved Hide resolved

If the archive is empty and ``self._initial_solutions`` is set, we
return ``self._initial_solutions``. If ``self._initial_solutions`` is
not set and the archive is still empty, we operate on the initial
solution (x0) provided. Otherwise, we sample parents from the archive
to be used as input to our operator
svott03 marked this conversation as resolved.
Show resolved Hide resolved

Returns:
If the archive is not empty, ``(batch_size, solution_dim)`` array
svott03 marked this conversation as resolved.
Show resolved Hide resolved
-- contains ``batch_size`` new solutions to evaluate. If the
archive is empty, we return ``self._initial_solutions``, which
might not have ``batch_size`` solutions.
"""

if self._os == 'isoline':
svott03 marked this conversation as resolved.
Show resolved Hide resolved
if self.archive.empty and self._initial_solutions is not None:
return np.clip(self._initial_solutions, self.lower_bounds,
self.upper_bounds)

if self.archive.empty:
iso_gaussian = self._rng.normal(
scale=self._iso_sigma,
size=(self._batch_size, self.solution_dim),
).astype(self.archive.dtype)

solution_batch = np.expand_dims(self._x0, axis=0) + iso_gaussian
return np.clip(solution_batch, self.lower_bounds,
self.upper_bounds)
else:
parents = self.archive.sample_elites(
2 * self._batch_size)["solution"]
return self._operator.ask(
parents=parents.reshape(2, self._batch_size, -1))
else: # self._os == 'gaussian'
if self.archive.empty:
if self._initial_solutions is not None:
return np.clip(self._initial_solutions, self.lower_bounds,
self.upper_bounds)
parents = np.repeat(self.x0[None],
repeats=self._batch_size,
axis=0)
else:
parents = self.archive.sample_elites(
self._batch_size)["solution"]

return self._operator.ask(parents=parents)
178 changes: 178 additions & 0 deletions tests/emitters/genetic_algorithm_emitter_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""Tests for EvolutionStrategyEmitter."""
import numpy as np
import pytest

from ribs.emitters import GeneticAlgorithmEmitter

# from pymoo.operators.mutation.gauss import GaussianMutation
svott03 marked this conversation as resolved.
Show resolved Hide resolved
# from ribs.archives import GridArchive


def test_properties_are_correct(archive_fixture):
archive, x0 = archive_fixture
iso_sigma = 1
line_sigma = 2
batch_size = 2
emitter = GeneticAlgorithmEmitter(archive,
iso_sigma=iso_sigma,
line_sigma=line_sigma,
x0=x0,
batch_size=batch_size,
os="isoline")

assert np.all(emitter.x0 == x0)
assert emitter.iso_sigma == iso_sigma
assert emitter.line_sigma == line_sigma
assert emitter.batch_size == batch_size


def test_initial_solutions_is_correct(archive_fixture):
archive, _ = archive_fixture
initial_solutions = [[0, 1, 2, 3], [-1, -2, -3, -4]]
emitter = GeneticAlgorithmEmitter(archive,
initial_solutions=initial_solutions,
os="isoline")

assert np.all(emitter.ask() == initial_solutions)
assert np.all(emitter.initial_solutions == initial_solutions)


def test_initial_solutions_shape(archive_fixture):
archive, _ = archive_fixture
initial_solutions = [[0, 0, 0], [1, 1, 1]]

# archive.solution_dim = 4
with pytest.raises(ValueError):
GeneticAlgorithmEmitter(archive,
initial_solutions=initial_solutions,
os="isoline")


def test_neither_x0_nor_initial_solutions_provided(archive_fixture):
archive, _ = archive_fixture
with pytest.raises(ValueError):
GeneticAlgorithmEmitter(archive)


def test_both_x0_and_initial_solutions_provided(archive_fixture):
archive, x0 = archive_fixture
initial_solutions = [[0, 1, 2, 3], [-1, -2, -3, -4]]
with pytest.raises(ValueError):
GeneticAlgorithmEmitter(archive,
x0=x0,
initial_solutions=initial_solutions,
os="isoline")


def test_upper_bounds_enforced(archive_fixture):
archive, _ = archive_fixture
emitter = GeneticAlgorithmEmitter(archive,
x0=[2, 2, 2, 2],
iso_sigma=0,
line_sigma=0,
bounds=[(-1, 1)] * 4,
os="isoline")
sols = emitter.ask()
assert np.all(sols <= 1)


def test_lower_bounds_enforced(archive_fixture):
archive, _ = archive_fixture
emitter = GeneticAlgorithmEmitter(archive,
x0=[-2, -2, -2, -2],
iso_sigma=0,
line_sigma=0,
bounds=[(-1, 1)] * 4,
os="isoline")
sols = emitter.ask()
assert np.all(sols >= -1)


def test_degenerate_iso_gauss_emits_x0(archive_fixture):
archive, x0 = archive_fixture
emitter = GeneticAlgorithmEmitter(archive,
x0=x0,
iso_sigma=0,
batch_size=2,
os="isoline")
solutions = emitter.ask()
assert (solutions == np.expand_dims(x0, axis=0)).all()


def test_degenerate_iso_gauss_emits_parent(archive_fixture):
archive, x0 = archive_fixture
emitter = GeneticAlgorithmEmitter(archive,
x0=x0,
iso_sigma=0,
batch_size=2,
os="isoline")
archive.add_single(x0, 1, np.array([0, 0]))

solutions = emitter.ask()

assert (solutions == np.expand_dims(x0, axis=0)).all()


def test_degenerate_iso_gauss_emits_along_line(archive_fixture):
archive, x0 = archive_fixture
emitter = GeneticAlgorithmEmitter(archive,
x0=x0,
iso_sigma=0,
batch_size=100,
os="isoline")
archive.add_single(np.array([0, 0, 0, 0]), 1, np.array([0, 0]))
archive.add_single(np.array([10, 0, 0, 0]), 1, np.array([1, 1]))

solutions = emitter.ask()

# All solutions should either come from a degenerate distribution around
# [0,0,0,0], a degenerate distribution around [10,0,0,0], or the "iso line
# distribution" between [0,0,0,0] and [10,0,0,0] (i.e. a line between those
# two points). By having a large batch size, we should be able to cover all
# cases. In any case, this assertion should hold for all solutions
# generated.
assert (solutions[:, 1:] == 0).all()


def test_degenerate_gauss_emits_x0(archive_fixture):
archive, x0 = archive_fixture
emitter = GeneticAlgorithmEmitter(archive,
sigma=0,
x0=x0,
batch_size=2,
os="gaussian")
solutions = emitter.ask()
assert (solutions == np.expand_dims(x0, axis=0)).all()


def test_degenerate_gauss_emits_parent(archive_fixture):
archive, x0 = archive_fixture
parent_sol = x0 * 5
archive.add_single(parent_sol, 1, np.array([0, 0]))
emitter = GeneticAlgorithmEmitter(archive,
sigma=0,
x0=x0,
batch_size=2,
os="gaussian")

# All solutions should be generated "around" the single parent solution in
# the archive.
solutions = emitter.ask()
assert (solutions == np.expand_dims(parent_sol, axis=0)).all()


# def test_pymoo_gaussian_operator_error_free():
svott03 marked this conversation as resolved.
Show resolved Hide resolved
# x0 = np.array([1, 2, 3, 4])
# bounds = [(1, 4), (1, 4), (1, 4), (1, 4)]
# operator = GaussianMutation(sigma=0.1)
# archive = GridArchive(solution_dim=4,
# dims=[20, 20],
# ranges=[(-1.0, 1.0)] * 2)
# emitter = GeneticAlgorithmEmitter(archive,
# operator=operator,
# x0=x0,
# batch_size=1,
# bounds=bounds,
# os="pymooGaussian")
# solution = emitter.ask()
# assert len(solution[0]) == len(x0)