diff --git a/HISTORY.md b/HISTORY.md index fdea92486..93c52ae16 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,6 +6,9 @@ #### API +- Implement CMA-MAE archive thresholds (#256) + - Revive the old implementation of `add_single` removed in (#221) + - Add separate tests for `add_single` and `add` with single solution - Fix all examples and tutorials (#253) - Add restart timer to `EvolutionStrategyEmitter` and `GradientAborescenceEmitter`(#255) - Rename fields and update documentation (#249, #250) diff --git a/docs/tutorials.md b/docs/tutorials.md index 21f6a4c5f..ae2e4e39a 100644 --- a/docs/tutorials.md +++ b/docs/tutorials.md @@ -7,6 +7,7 @@ tutorials/lunar_lander tutorials/lsi_mnist tutorials/arm_repertoire tutorials/fooling_mnist +tutorials/cma_mae ``` Tutorials are Python notebooks with detailed explanations of pyribs usage. They @@ -23,3 +24,4 @@ needs for execution. | {doc}`tutorials/lsi_mnist` | {class}`~ribs.archives.GridArchive` | {class}`~ribs.emitters.EvolutionStrategyEmitter` | {class}`~ribs.schedulers.Scheduler` | | {doc}`tutorials/arm_repertoire` | {class}`~ribs.archives.CVTArchive` | {class}`~ribs.emitters.EvolutionStrategyEmitter` | {class}`~ribs.schedulers.Scheduler` | | {doc}`tutorials/fooling_mnist` | {class}`~ribs.archives.GridArchive` | {class}`~ribs.emitters.GaussianEmitter` | {class}`~ribs.schedulers.Scheduler` | +| {doc}`tutorials/cma_mae` | {class}`~ribs.archives.GridArchive` | {class}`~ribs.emitters.EvolutionStrategyEmitter` | {class}`~ribs.schedulers.Scheduler` | diff --git a/examples/sphere.py b/examples/sphere.py index 0782f9e60..3772d3e35 100644 --- a/examples/sphere.py +++ b/examples/sphere.py @@ -34,18 +34,22 @@ - `cma_mega`: GridArchive with GradientAborescenceEmitter. - `cma_mega_adam`: GridArchive with GradientAborescenceEmitter using Adam Optimizer. - -Note: the settings for `cma_mega` and `cma_mega_adam` are consistent with the -paper (`Fontaine 2021 `_) in which these -algorithms are proposed. +- `cma_mae`: GridArchive (learning_rate = 0.01) with EvolutionStrategyEmitter + using ImprovementRanker. +- `cma_maega`: GridArchive (learning_rate = 0.01) with + GradientAborescenceEmitter using ImprovementRanker. All algorithms use 15 emitters, each with a batch size of 37. Each one runs for 4500 iterations for a total of 15 * 37 * 4500 ~= 2.5M evaluations. -Note that the CVTArchive in this example uses 10,000 cells, as opposed to the -250,000 (500x500) in the GridArchive, so it is not fair to directly compare -`cvt_map_elites` and `line_cvt_map_elites` to the other algorithms. However, the -other algorithms may be fairly compared because they use the same archive. +Notes: +- `cma_mega` and `cma_mega_adam` use only one emitter and run for 10,000 + iterations. This is to be consistent with the paper (`Fontaine 2021 + `_) in which these algorithms were proposed. +- `cma_mae` and `cma_maega` run for 10,000 iterations as well. +- CVTArchive in this example uses 10,000 cells, as opposed to the 250,000 + (500x500) in the GridArchive, so it is not fair to directly compare + `cvt_map_elites` and `line_cvt_map_elites` to the other algorithms. Outputs are saved in the `sphere_output/` directory by default. The archive is saved as a CSV named `{algorithm}_{dim}_archive.csv`, while snapshots of the @@ -142,46 +146,58 @@ def sphere(solution_batch): ) -def create_scheduler(algorithm, dim, seed): +def create_scheduler(algorithm, + solution_dim, + archive_dims, + learning_rate, + use_result_archive=True, + seed=None): """Creates a scheduler based on the algorithm name. Args: algorithm (str): Name of the algorithm passed into sphere_main. - dim (int): Dimensionality of the sphere function. + solution_dim(int): Dimensionality of the sphere function. + archive_dims (int): Dimensionality of the archive. + learning_rate (float): Learning rate of archive. + use_result_archive (bool): Whether to use a separate archive to store + the results. seed (int): Main seed or the various components. Returns: - scheduler: A ribs scheduler for running the algorithm. + ribs.schedulers.Scheduler: A ribs scheduler for running the algorithm. """ - max_bound = dim / 2 * 5.12 + max_bound = solution_dim / 2 * 5.12 bounds = [(-max_bound, max_bound), (-max_bound, max_bound)] - initial_sol = np.zeros(dim) + initial_sol = np.zeros(solution_dim) batch_size = 37 num_emitters = 15 + mode = "batch" + threshold_min = -np.inf # default + + if algorithm in ["cma_mae", "cma_maega"]: + threshold_min = 0 # Create archive. - if algorithm in [ - "map_elites", "line_map_elites", "cma_me_imp", "cma_me_imp_mu", - "cma_me_rd", "cma_me_rd_mu", "cma_me_opt", "cma_me_mixed" - ]: - archive = GridArchive(solution_dim=dim, - dims=(500, 500), - ranges=bounds, - seed=seed) - elif algorithm in ["cvt_map_elites", "line_cvt_map_elites"]: - archive = CVTArchive(solution_dim=dim, + if algorithm in ["cvt_map_elites", "line_cvt_map_elites"]: + archive = CVTArchive(solution_dim=solution_dim, cells=10_000, ranges=bounds, samples=100_000, use_kd_tree=True) - elif algorithm in ["cma_mega", "cma_mega_adam"]: - # Note that the archive is smaller for these algorithms. This is to be - # consistent with Fontaine 2021 . - archive = GridArchive(solution_dim=dim, - dims=(100, 100), + else: + archive = GridArchive(solution_dim=solution_dim, + dims=archive_dims, ranges=bounds, + learning_rate=learning_rate, + threshold_min=threshold_min, seed=seed) - else: - raise ValueError(f"Algorithm `{algorithm}` is not recognized") + + # Create result archive. + result_archive = None + if use_result_archive: + result_archive = GridArchive(solution_dim=solution_dim, + dims=archive_dims, + ranges=bounds, + seed=seed) # Create emitters. Each emitter needs a different seed, so that they do not # all do the same thing. @@ -278,7 +294,38 @@ def create_scheduler(algorithm, dim, seed): batch_size=batch_size - 1, # 1 solution is returned by ask_dqd seed=emitter_seeds[0]) ] - return Scheduler(archive, emitters) + elif algorithm == "cma_mae": + emitters = [ + EvolutionStrategyEmitter( + archive=archive, + x0=initial_sol, + sigma0=0.5, + ranker="imp", + selection_rule="mu", + restart_rule="basic", + batch_size=batch_size, + seed=s, + ) for s in emitter_seeds + ] + elif algorithm in ["cma_maega"]: + emitters = [ + GradientAborescenceEmitter(archive, + initial_sol, + sigma0=10.0, + step_size=1.0, + ranker="imp", + grad_opt="gradient_ascent", + restart_rule="basic", + bounds=None, + batch_size=batch_size, + seed=s) for s in emitter_seeds + ] + + print( + f"Created Scheduler for {algorithm} with learning rate {learning_rate} " + f"and add mode {mode}, using solution dim {solution_dim} and archive " + f"dims {archive_dims}.") + return Scheduler(archive, emitters, result_archive, add_mode=mode) def save_heatmap(archive, heatmap_path): @@ -304,6 +351,8 @@ def save_heatmap(archive, heatmap_path): def sphere_main(algorithm, dim=None, itrs=None, + archive_dims=None, + learning_rate=None, outdir="sphere_output", log_freq=250, seed=None): @@ -311,24 +360,30 @@ def sphere_main(algorithm, Args: algorithm (str): Name of the algorithm. - dim (int): Dimensionality of solutions. + dim (int): Dimensionality of the sphere function. itrs (int): Iterations to run. + archive_dims (tuple): Dimensionality of the archive. + learning_rate (float): The archive learning rate. outdir (str): Directory to save output. log_freq (int): Number of iterations to wait before recording metrics and saving heatmap. seed (int): Seed for the algorithm. By default, there is no seed. """ + # Use default dim for each algorithm. if dim is None: - if algorithm in ["cma_mega", "cma_mega_adam"]: + if algorithm in ["cma_mega", "cma_mega_adam", "cma_maega"]: dim = 1_000 + elif algorithm in ["cma_mae"]: + dim = 100 elif algorithm in [ "map_elites", "line_map_elites", "cma_me_imp", "cma_me_imp_mu", "cma_me_rd", "cma_me_rd_mu", "cma_me_opt", "cma_me_mixed" ]: dim = 20 + # Use default itrs for each algorithm. if itrs is None: - if algorithm in ["cma_mega", "cma_mega_adam"]: + if algorithm in ["cma_mega", "cma_mega_adam", "cma_mae", "cma_maega"]: itrs = 10_000 elif algorithm in [ "map_elites", "line_map_elites", "cma_me_imp", "cma_me_imp_mu", @@ -336,15 +391,42 @@ def sphere_main(algorithm, ]: itrs = 4500 + # Use default archive_dim for each algorithm. + if archive_dims is None: + if algorithm in ["cma_mega", "cma_mega_adam", "cma_mae", "cma_maega"]: + archive_dims = (100, 100) + elif algorithm in [ + "map_elites", "line_map_elites", "cma_me_imp", "cma_me_imp_mu", + "cma_me_rd", "cma_me_rd_mu", "cma_me_opt", "cma_me_mixed" + ]: + archive_dims = (500, 500) + + # Use default learning_rate for each algorithm. + if learning_rate is None: + if algorithm in ["cma_mae", "cma_maega"]: + learning_rate = 0.01 + elif algorithm in [ + "map_elites", "line_map_elites", "cma_me_imp", "cma_me_imp_mu", + "cma_me_rd", "cma_me_rd_mu", "cma_me_opt", "cma_me_mixed", + "cma_mega", "cma_mega_adam" + ]: + learning_rate = 1.0 + name = f"{algorithm}_{dim}" outdir = Path(outdir) if not outdir.is_dir(): outdir.mkdir() - is_dqd = algorithm in ['cma_mega', 'cma_mega_adam'] + is_dqd = algorithm in ["cma_mega", "cma_mega_adam", "cma_maega"] + use_result_archive = algorithm in ["cma_mae", "cma_maega"] - scheduler = create_scheduler(algorithm, dim, seed) - archive = scheduler.archive + scheduler = create_scheduler(algorithm, + dim, + archive_dims, + learning_rate, + use_result_archive=use_result_archive, + seed=seed) + result_archive = scheduler.result_archive metrics = { "QD Score": { "x": [0], @@ -357,7 +439,7 @@ def sphere_main(algorithm, } non_logging_time = 0.0 - save_heatmap(archive, str(outdir / f"{name}_heatmap_{0:05d}.png")) + save_heatmap(result_archive, str(outdir / f"{name}_heatmap_{0:05d}.png")) for itr in tqdm.trange(1, itrs + 1): itr_start = time.time() @@ -380,19 +462,21 @@ def sphere_main(algorithm, final_itr = itr == itrs if itr % log_freq == 0 or final_itr: if final_itr: - archive.as_pandas(include_solutions=final_itr).to_csv( + result_archive.as_pandas(include_solutions=final_itr).to_csv( outdir / f"{name}_archive.csv") # Record and display metrics. metrics["QD Score"]["x"].append(itr) - metrics["QD Score"]["y"].append(archive.stats.qd_score) + metrics["QD Score"]["y"].append(result_archive.stats.qd_score) metrics["Archive Coverage"]["x"].append(itr) - metrics["Archive Coverage"]["y"].append(archive.stats.coverage) + metrics["Archive Coverage"]["y"].append( + result_archive.stats.coverage) print(f"Iteration {itr} | Archive Coverage: " f"{metrics['Archive Coverage']['y'][-1] * 100:.3f}% " f"QD Score: {metrics['QD Score']['y'][-1]:.3f}") - save_heatmap(archive, str(outdir / f"{name}_heatmap_{itr:05d}.png")) + save_heatmap(result_archive, + str(outdir / f"{name}_heatmap_{itr:05d}.png")) # Plot metrics. print(f"Algorithm Time (Excludes Logging and Setup): {non_logging_time}s") diff --git a/examples/tutorials/cma_mae.ipynb b/examples/tutorials/cma_mae.ipynb new file mode 100644 index 000000000..13a23a6f1 --- /dev/null +++ b/examples/tutorials/cma_mae.ipynb @@ -0,0 +1,35 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "69e0c236-9407-496f-be0d-9562f8191ce4", + "metadata": {}, + "source": [ + "# CMA-MAE and Archive Thresholds\n", + "\n", + "Coming soon!" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ribs/_utils.py b/ribs/_utils.py index eb9e4f97d..c6ce453ad 100644 --- a/ribs/_utils.py +++ b/ribs/_utils.py @@ -1,4 +1,18 @@ """Miscellaneous internal utilities.""" +import numpy as np + + +def check_finite(x, name): + """Checks that x is finite (i.e. not infinity or NaN). + + `x` must be either a scalar or NumPy array. + """ + if not np.all(np.isfinite(x)): + if np.isscalar(x): + raise ValueError(f"{name} must be finite (infinity " + "and NaN values are not supported).") + raise ValueError(f"All elements of {name} must be finite (infinity " + "and NaN values are not supported).") def check_batch_shape(array, array_name, dim, dim_name, extra_msg=""): diff --git a/ribs/archives/_archive_base.py b/ribs/archives/_archive_base.py index a09b10988..cd9b13893 100644 --- a/ribs/archives/_archive_base.py +++ b/ribs/archives/_archive_base.py @@ -5,12 +5,16 @@ import numpy as np from numpy_groupies import aggregate_nb as aggregate -from ribs._utils import (check_1d_shape, check_batch_shape, check_is_1d, - check_solution_batch_dim) +from ribs._utils import (check_1d_shape, check_batch_shape, check_finite, + check_is_1d, check_solution_batch_dim) from ribs.archives._archive_data_frame import ArchiveDataFrame from ribs.archives._archive_stats import ArchiveStats from ribs.archives._elite import Elite, EliteBatch +_ADD_WARNING = (" Note that starting in pyribs 0.5.0, add() takes in a " + "batch of solutions unlike in pyribs 0.4.0, where add() " + "only took in a single solution.") + def readonly(arr): """Sets an array to be readonly.""" @@ -60,11 +64,13 @@ class ArchiveBase(ABC): # pylint: disable = too-many-instance-attributes This class assumes all archives use a fixed-size container with cells that hold (1) information about whether the cell is occupied (bool), (2) a solution (1D array), (3) objective function evaluation of the solution - (float), (4) measure space coordinates of the solution (1D array), and (5) - any additional metadata associated with the solution (object). In this - class, the container is implemented with separate numpy arrays that share - common dimensions. Using the ``solution_dim``, ``cells`, and ``measure_dim`` - arguments in ``__init__``, these arrays are as follows: + (float), (4) measure space coordinates of the solution (1D array), (5) + any additional metadata associated with the solution (object), and (6) a + threshold which determines how high an objective value must be for a + solution to be inserted into a cell (float). In this class, the container is + implemented with separate numpy arrays that share common dimensions. Using + the ``solution_dim``, ``cells`, and ``measure_dim`` arguments in + ``__init__``, these arrays are as follows: +------------------------+----------------------------+ | Name | Shape | @@ -79,6 +85,8 @@ class ArchiveBase(ABC): # pylint: disable = too-many-instance-attributes +------------------------+----------------------------+ | ``_metadata_arr`` | ``(cells,)`` | +------------------------+----------------------------+ + | ``_threshold_arr`` | ``(cells,)`` | + +------------------------+----------------------------+ All of these arrays are accessed via a common integer index. If we have index ``i``, we access its solution at ``_solution_arr[i]``, its measure @@ -96,11 +104,18 @@ class ArchiveBase(ABC): # pylint: disable = too-many-instance-attributes .. note:: Attributes beginning with an underscore are only intended to be accessed by child classes (i.e. they are "protected" attributes). + .. note:: The idea of archive thresholds was introduced in `Fontaine 2022 + `_. Refer to our `CMA-MAE tutorial + <../../tutorials/cma_mae.html>`_ for more info on thresholds, including + the ``learning_rate`` and ``threshold_min`` parameters. + Args: solution_dim (int): Dimension of the solution space. cells (int): Number of cells in the archive. This is used to create the numpy arrays described above for storing archive info. measure_dim (int): The dimension of the measure space. + learning_rate (float): The learning rate for threshold updates. + threshold_min (float): The initial threshold value for all the cells. seed (int): Value to seed the random number generator. Set to None to avoid a fixed seed. dtype (str or data-type): Data type of the solutions, objectives, @@ -122,6 +137,8 @@ class ArchiveBase(ABC): # pylint: disable = too-many-instance-attributes coordinates of each solution. _metadata_arr (numpy.ndarray): Object array storing the metadata associated with each solution. + _threshold_arr (numpy.ndarray): Float array storing the threshold for + insertion into each cell. _occupied_indices (numpy.ndarray): A ``(cells,)`` array of integer (``np.int32``) indices that are occupied in the archive. This could be a list, but for efficiency, we make it a fixed-size array, where @@ -134,6 +151,8 @@ def __init__(self, solution_dim, cells, measure_dim, + learning_rate=1.0, + threshold_min=-np.inf, seed=None, dtype=np.float64): @@ -155,6 +174,15 @@ def __init__(self, dtype=self.dtype) self._metadata_arr = np.empty(self._cells, dtype=object) + if threshold_min == -np.inf and learning_rate != 1.0: + raise ValueError("threshold_min can only be -np.inf if " + "learning_rate is 1.0") + self._learning_rate = self._dtype(learning_rate) + self._threshold_min = self._dtype(threshold_min) + self._threshold_arr = np.full(self._cells, + threshold_min, + dtype=self.dtype) + self._stats = None self._stats_reset() @@ -207,6 +235,16 @@ def solution_dim(self): """int: Dimensionality of the solutions in the archive.""" return self._solution_dim + @property + def learning_rate(self): + """float: The learning rate for threshold updates.""" + return self._learning_rate + + @property + def threshold_min(self): + """float: The initial threshold value for all the cells.""" + return self._threshold_min + @property def stats(self): """:class:`ArchiveStats`: Statistics about the archive. @@ -251,6 +289,67 @@ def _stats_reset(self): self._stats = ArchiveStats(0, self.dtype(0.0), self.dtype(0.0), None, None) + def _compute_new_thresholds(self, threshold_arr, objective_batch, + index_batch, learning_rate): + """Update thresholds. + + Args: + threshold_arr (np.ndarray): The threshold of the cells before + updating. 1D array. + objective_batch (np.ndarray): The objective values of the solution + that is inserted into the archive for each cell. 1D array. We + assume that the objective values are all higher than the + thresholds of their respective cells. + index_batch (np.ndarray): The archive index of the elements in + objective batch. + Returns: + `new_threshold_batch` (A self.dtype array of new + thresholds) and `threshold_update_indices` (A boolean + array indicating which entries in `threshold_arr` should + be updated. + """ + # Even though we do this check, it should not be possible to have + # empty objective_batch or index_batch in the add() method since + # we check that at least one cell is being updated by seeing if + # can_insert has any True values. + if objective_batch.size == 0 or index_batch.size == 0: + return np.array([], dtype=self.dtype), np.array([], dtype=bool) + + # Compute the number of objectives inserted into each cell. + objective_sizes = aggregate(index_batch, + objective_batch, + func="len", + fill_value=0, + size=threshold_arr.size) + + # These indices are with respect to the archive, so we can directly pass + # them to threshold_arr. + threshold_update_indices = objective_sizes > 0 + + # Compute the sum of the objectives inserted into each cell. + objective_sums = aggregate(index_batch, + objective_batch, + func="sum", + fill_value=np.nan, + size=threshold_arr.size) + + # Throw away indices that we do not care about. + objective_sizes = objective_sizes[threshold_update_indices] + objective_sums = objective_sums[threshold_update_indices] + + # Unlike in add_single, we do not need to worry about + # old_threshold having -np.inf here as a result of threshold_min + # being -np.inf. This is because the case with threshold_min = + # -np.inf is handled separately since we compute the new + # threshold based on the max objective in each cell in that case. + old_threshold = np.copy(threshold_arr[threshold_update_indices]) + + ratio = self.dtype(1.0 - learning_rate)**objective_sizes + new_threshold_batch = (ratio * old_threshold + + (objective_sums / objective_sizes) * (1 - ratio)) + + return new_threshold_batch, threshold_update_indices + def clear(self): """Removes all elites from the archive. @@ -299,15 +398,13 @@ def index_of_single(self, measures): storage arrays. Raises: ValueError: ``measures`` is not of shape (:attr:`measure_dim`,). + ValueError: ``measures`` has non-finite values (inf or NaN). """ measures = np.asarray(measures) check_1d_shape(measures, "measures", self.measure_dim, "measure_dim") + check_finite(measures, "measures") return self.index_of(measures[None])[0] - _ADD_WARNING = (" Note that starting in pyribs 0.5.0, add() takes in a " - "batch of solutions unlike in pyribs 0.4.0, where add() " - "only took in a single solution.") - def add(self, solution_batch, objective_batch, @@ -315,12 +412,21 @@ def add(self, metadata_batch=None): """Inserts a batch of solutions into the archive. - Each solution is only inserted if it has a higher objective than the - elite previously in the corresponding cell. If multiple solutions in the - batch end up in the same cell, we only insert the solution with the - highest objective. If multiple solutions end up in the same cell and tie - for the highest objective, we insert the solution that appears first in - the batch. + Each solution is only inserted if it has a higher ``objective`` than the + threshold of the corresponding cell. For the default values of + ``learning_rate`` and ``threshold_min``, this threshold is simply the + objective value of the elite previously in the cell. If multiple + solutions in the batch end up in the same cell, we only insert the + solution with the highest objective. If multiple solutions end up in the + same cell and tie for the highest objective, we insert the solution that + appears first in the batch. + + For the default values of ``learning_rate`` and ``threshold_min``, the + threshold for each cell is updated by taking the maximum objective value + among all the solutions that landed in the cell, resulting in the same + behavior as in the vanilla MAP-Elites archive. However, for other + settings, the threshold is updated with the batch update rule described + in the appendix of `Fontaine 2022 `_. .. note:: The indices of all arguments should "correspond" to each other, i.e. ``solution_batch[i]``, ``objective_batch[i]``, @@ -376,8 +482,10 @@ def add(self, status_batch]``. - **value_batch** (:attr:`dtype`): An array with values for each - solution in the batch. The meaning of each ``value`` depends on - the corresponding ``status``: + solution in the batch. With the default values of ``learning_rate + = 1.0`` and ``threshold_min = -np.inf``, the meaning of each value + depends on the corresponding ``status`` and is identical to that + in CMA-ME (`Fontaine 2020 `_): - ``0`` (not added): The value is the "negative improvement," i.e. the objective of the solution passed in minus the objective of @@ -389,8 +497,15 @@ def add(self, of the elite previously in the archive. - ``2`` (new cell): The value is just the objective of the solution. + + In contrast, for other values of ``learning_rate`` and + ``threshold_min``, each value is equivalent to the objective value + of the solution minus the threshold of its corresponding cell in + the archive. Raises: ValueError: The array arguments do not match their specified shapes. + ValueError: ``objective_batch`` or ``measures_batch`` has non-finite + values (inf or NaN). """ self._state["add"] += 1 @@ -398,35 +513,37 @@ def add(self, solution_batch = np.asarray(solution_batch) check_batch_shape(solution_batch, "solution_batch", self.solution_dim, - "solution_dim", self._ADD_WARNING) + "solution_dim", _ADD_WARNING) batch_size = solution_batch.shape[0] objective_batch = np.asarray(objective_batch, self.dtype) - check_is_1d(objective_batch, "objective_batch", self._ADD_WARNING) + check_is_1d(objective_batch, "objective_batch", _ADD_WARNING) check_solution_batch_dim(objective_batch, "objective_batch", batch_size, is_1d=True, - extra_msg=self._ADD_WARNING) + extra_msg=_ADD_WARNING) + check_finite(objective_batch, "objective_batch") measures_batch = np.asarray(measures_batch) check_batch_shape(measures_batch, "measures_batch", self.measure_dim, - "measure_dim", self._ADD_WARNING) + "measure_dim", _ADD_WARNING) check_solution_batch_dim(measures_batch, "measures_batch", batch_size, is_1d=False, - extra_msg=self._ADD_WARNING) + extra_msg=_ADD_WARNING) + check_finite(measures_batch, "measures_batch") metadata_batch = (np.empty(batch_size, dtype=object) if metadata_batch is None else np.asarray(metadata_batch, dtype=object)) - check_is_1d(metadata_batch, "metadata_batch", self._ADD_WARNING) + check_is_1d(metadata_batch, "metadata_batch", _ADD_WARNING) check_solution_batch_dim(metadata_batch, "metadata_batch", batch_size, is_1d=True, - extra_msg=self._ADD_WARNING) + extra_msg=_ADD_WARNING) ## Step 2: Compute status_batch and value_batch ## @@ -435,21 +552,30 @@ def add(self, # Copy old objectives since we will be modifying the objectives storage. old_objective_batch = np.copy(self._objective_arr[index_batch]) + old_threshold_batch = np.copy(self._threshold_arr[index_batch]) # Compute the statuses -- these are all boolean arrays of length # batch_size. already_occupied = self._occupied_arr[index_batch] - is_new = ~already_occupied - improve_existing = (objective_batch > - old_objective_batch) & already_occupied + # In the case where we want CMA-ME behavior, threshold_arr[index] + # is -inf for new cells, which satisfies the condition for can_be_added. + can_be_added = objective_batch > old_threshold_batch + is_new = can_be_added & ~already_occupied + improve_existing = can_be_added & already_occupied status_batch = np.zeros(batch_size, dtype=np.int32) status_batch[is_new] = 2 status_batch[improve_existing] = 1 - # Since we set the new solutions in old_objective_batch to have - # value 0.0, the values for new solutions are correct here. - old_objective_batch[is_new] = 0.0 - value_batch = objective_batch - old_objective_batch + # New solutions require special settings for old_objective and + # old_threshold. + old_objective_batch[is_new] = self.dtype(0) + + # If threshold_min is -inf, then we want CMA-ME behavior, which + # will compute the improvement value of new solutions w.r.t zero. + # Otherwise, we will compute w.r.t. threshold_min. + old_threshold_batch[is_new] = (self.dtype(0) if self._threshold_min + == -np.inf else self._threshold_min) + value_batch = objective_batch - old_threshold_batch ## Step 3: Insert solutions into archive. ## @@ -512,6 +638,24 @@ def add(self, index_batch_insert[is_new_and_inserted]) self._num_occupied += n_new + # Update the thresholds. + if self._threshold_min == -np.inf: + # Here we want regular archive behavior, so the thresholds + # should just be the maximum objective. + self._threshold_arr[index_batch_insert] = objective_batch_insert + else: + # Here we compute the batch threshold update described in the + # appendix of Fontaine 2022 https://arxiv.org/abs/2205.10752 + # This computation is based on the mean objective of all + # solutions in the batch that could have been inserted into + # each cell. This method is separated out to facilitate + # testing. + (new_thresholds, + update_thresholds_indices) = self._compute_new_thresholds( + self._threshold_arr, objective_batch_can, index_batch_can, + self._learning_rate) + self._threshold_arr[update_thresholds_indices] = new_thresholds + ## Step 4: Update archive stats. ## # Since we set the new solutions in the old objective batch to have @@ -526,9 +670,9 @@ def add(self, if self._stats.obj_max is None or max_obj_insert > self._stats.obj_max: new_obj_max = max_obj_insert self._best_elite = Elite( - readonly(solution_batch_insert[max_idx]), + readonly(np.copy(solution_batch_insert[max_idx])), objective_batch_insert[max_idx], - readonly(measures_batch_insert[max_idx]), + readonly(np.copy(measures_batch_insert[max_idx])), index_batch_insert[max_idx], metadata_batch_insert[max_idx], ) @@ -548,8 +692,17 @@ def add(self, def add_single(self, solution, objective, measures, metadata=None): """Inserts a single solution into the archive. - The solution is only inserted if it has a higher ``objective`` - than the elite previously in the corresponding cell. + The solution is only inserted if it has a higher ``objective`` than the + threshold of the corresponding cell. For the default values of + ``learning_rate`` and ``threshold_min``, this threshold is simply the + objective value of the elite previously in the cell. The threshold is + also updated if the solution was inserted. + + .. note:: + To make it more amenable to modifications, this method's + implementation is designed to be readable at the cost of + performance, e.g., none of its operations are modified. If you need + performance, we recommend using :meth:`add`. Args: solution (array-like): Parameters of the solution. @@ -564,23 +717,96 @@ def add_single(self, solution, objective, measures, metadata=None): array-like objects as metadata may lead to unexpected behavior. However, the metadata may be a dict or other object which *contains* arrays. + Raises: + ValueError: The array arguments do not match their specified shapes. + ValueError: ``objective`` is non-finite (inf or NaN) or ``measures`` + has non-finite values. Returns: tuple: 2-element tuple of (status, value) describing the result of the add operation. Refer to :meth:`add` for the meaning of the status and value. """ - solution = np.asarray(solution) - objective = np.asarray(objective, dtype=self.dtype) # 0-dim array. - measures = np.asarray(measures) + self._state["add"] += 1 + solution = np.asarray(solution) check_1d_shape(solution, "solution", self.solution_dim, "solution_dim") + + objective = self.dtype(objective) + check_finite(objective, "objective") + + measures = np.asarray(measures) check_1d_shape(measures, "measures", self.measure_dim, "measure_dim") + check_finite(measures, "measures") + index = self.index_of_single(measures) + + # Only used for computing QD score. + old_objective = self._objective_arr[index] + + # Used for computing improvement value. + old_threshold = self._threshold_arr[index] + + # New solutions require special settings for old_objective and + # old_threshold. + was_occupied = self._occupied_arr[index] + if not was_occupied: + old_objective = self.dtype(0) + # If threshold_min is -inf, then we want CMA-ME behavior, which will + # compute the improvement value w.r.t. zero for new solutions. + # Otherwise, we will compute w.r.t. threshold_min. + old_threshold = (self.dtype(0) if self._threshold_min == -np.inf + else self._threshold_min) + + status = 0 # NOT_ADDED + # In the case where we want CMA-ME behavior, threshold_arr[index] + # is -inf for new cells, which satisfies this if condition. + if self._threshold_arr[index] < objective: + if was_occupied: + status = 1 # IMPROVE_EXISTING + else: + # Set this index to be occupied. + self._occupied_arr[index] = True + self._occupied_indices[self._num_occupied] = index + self._num_occupied += 1 + + status = 2 # NEW + + # This calculation works in the case where threshold_min is -inf + # because old_threshold will be set to 0.0 instead. + self._threshold_arr[index] = (old_threshold * + (1.0 - self._learning_rate) + + objective * self._learning_rate) + + # Insert into the archive. + self._objective_arr[index] = objective + self._measures_arr[index] = measures + self._solution_arr[index] = solution + self._metadata_arr[index] = metadata + + if status: + # Update archive stats. + new_qd_score = self._stats.qd_score + (objective - old_objective) + + if self._stats.obj_max is None or objective > self._stats.obj_max: + new_obj_max = objective + self._best_elite = Elite( + readonly(np.copy(self._solution_arr[index])), + objective, + readonly(np.copy(self._measures_arr[index])), + index, + metadata, + ) + else: + new_obj_max = self._stats.obj_max + + self._stats = ArchiveStats( + num_elites=len(self), + coverage=self.dtype(len(self) / self.cells), + qd_score=new_qd_score, + obj_max=new_obj_max, + obj_mean=new_qd_score / self.dtype(len(self)), + ) - status_batch, value_batch = self.add(solution[None], - np.array([objective]), - measures[None], - np.array([metadata], dtype=object)) - return (status_batch[0], value_batch[0]) + return status, objective - old_threshold def elites_with_measures(self, measures_batch): """Retrieves the elites with measures in the same cells as the measures @@ -632,10 +858,12 @@ def elites_with_measures(self, measures_batch): Raises: ValueError: ``measures_batch`` is not of shape (batch_size, :attr:`measure_dim`). + ValueError: ``measures_batch`` has non-finite values (inf or NaN). """ measures_batch = np.asarray(measures_batch) check_batch_shape(measures_batch, "measures_batch", self.measure_dim, "measure_dim") + check_finite(measures_batch, "measures_batch") index_batch = self.index_of(measures_batch) occupied_batch = self._occupied_arr[index_batch] @@ -700,9 +928,11 @@ def elites_with_measures_single(self, measures): described in :meth:`elites_with_measures`. Raises: ValueError: ``measures`` is not of shape (:attr:`measure_dim`,). + ValueError: ``measures`` has non-finite values (inf or NaN). """ measures = np.asarray(measures) check_1d_shape(measures, "measures", self.measure_dim, "measure_dim") + check_finite(measures, "measures") elite_batch = self.elites_with_measures(measures[None]) return Elite( diff --git a/ribs/archives/_cvt_archive.py b/ribs/archives/_cvt_archive.py index b97691d71..3148406e8 100644 --- a/ribs/archives/_cvt_archive.py +++ b/ribs/archives/_cvt_archive.py @@ -5,7 +5,7 @@ from scipy.spatial import cKDTree # pylint: disable=no-name-in-module from sklearn.cluster import k_means -from ribs._utils import check_batch_shape +from ribs._utils import check_batch_shape, check_finite from ribs.archives._archive_base import ArchiveBase @@ -50,6 +50,11 @@ class CVTArchive(ArchiveBase): and pass them into ``custom_centroids`` when constructing archives for subsequent experiments. + .. note:: The idea of archive thresholds was introduced in `Fontaine 2022 + `_. Refer to our `CMA-MAE tutorial + <../../tutorials/cma_mae.html>`_ for more info on thresholds, including + the ``learning_rate`` and ``threshold_min`` parameters. + Args: solution_dim (int): Dimension of the solution space. cells (int): The number of cells to use in the archive, equivalent to @@ -60,6 +65,8 @@ class CVTArchive(ArchiveBase): (inclusive), and the second dimension should have bounds :math:`[-2,2]` (inclusive). ``ranges`` should be the same length as ``dims``. + learning_rate (float): The learning rate for threshold updates. + threshold_min (float): The initial threshold value for all the cells. seed (int): Value to seed the random number generator as well as :func:`~sklearn.cluster.k_means`. Set to None to avoid a fixed seed. dtype (str or data-type): Data type of the solutions, objectives, @@ -93,6 +100,8 @@ def __init__(self, solution_dim, cells, ranges, + learning_rate=1.0, + threshold_min=-np.inf, seed=None, dtype=np.float64, samples=100_000, @@ -106,6 +115,8 @@ def __init__(self, solution_dim=solution_dim, cells=cells, measure_dim=len(ranges), + learning_rate=learning_rate, + threshold_min=threshold_min, seed=seed, dtype=dtype, ) @@ -233,10 +244,12 @@ def index_of(self, measures_batch): Raises: ValueError: ``measures_batch`` is not of shape (batch_size, :attr:`measure_dim`). + ValueError: ``measures_batch`` has non-finite values (inf or NaN). """ measures_batch = np.asarray(measures_batch) check_batch_shape(measures_batch, "measures_batch", self.measure_dim, "measure_dim") + check_finite(measures_batch, "measures_batch") if self._use_kd_tree: return np.asarray( diff --git a/ribs/archives/_grid_archive.py b/ribs/archives/_grid_archive.py index 766203aea..cc0e6150f 100644 --- a/ribs/archives/_grid_archive.py +++ b/ribs/archives/_grid_archive.py @@ -1,7 +1,7 @@ """Contains the GridArchive.""" import numpy as np -from ribs._utils import check_batch_shape, check_is_1d +from ribs._utils import check_batch_shape, check_finite, check_is_1d from ribs.archives._archive_base import ArchiveBase @@ -15,6 +15,11 @@ class GridArchive(ArchiveBase): solution that `maximizes` the objective function for the measures in that cell. + .. note:: The idea of archive thresholds was introduced in `Fontaine 2022 + `_. Refer to our `CMA-MAE tutorial + <../../tutorials/cma_mae.html>`_ for more info on thresholds, including + the ``learning_rate`` and ``threshold_min`` parameters. + Args: solution_dim (int): Dimension of the solution space. dims (array-like of int): Number of cells in each dimension of the @@ -32,6 +37,8 @@ class GridArchive(ArchiveBase): method -- refer to the implementation `here <../_modules/ribs/archives/_grid_archive.html#GridArchive.index_of>`_. Pass this parameter to configure that epsilon. + learning_rate (float): The learning rate for threshold updates. + threshold_min (float): The initial threshold value for all the cells. seed (int): Value to seed the random number generator. Set to None to avoid a fixed seed. dtype (str or data-type): Data type of the solutions, objectives, @@ -45,6 +52,8 @@ def __init__(self, solution_dim, dims, ranges, + learning_rate=1.0, + threshold_min=-np.inf, epsilon=1e-6, seed=None, dtype=np.float64): @@ -58,6 +67,8 @@ def __init__(self, solution_dim=solution_dim, cells=np.product(self._dims), measure_dim=len(self._dims), + learning_rate=learning_rate, + threshold_min=threshold_min, seed=seed, dtype=dtype, ) @@ -157,10 +168,12 @@ def index_of(self, measures_batch): Raises: ValueError: ``measures_batch`` is not of shape (batch_size, :attr:`measure_dim`). + ValueError: ``measures_batch`` has non-finite values (inf or NaN). """ measures_batch = np.asarray(measures_batch) check_batch_shape(measures_batch, "measures_batch", self.measure_dim, "measure_dim") + check_finite(measures_batch, "measures_batch") # Adding epsilon accounts for floating point precision errors from # transforming measures. We then cast to int32 to obtain integer diff --git a/ribs/emitters/rankers.py b/ribs/emitters/rankers.py index 1c059a397..e4e9ad821 100644 --- a/ribs/emitters/rankers.py +++ b/ribs/emitters/rankers.py @@ -117,8 +117,23 @@ def reset(self, emitter, archive, rng): class ImprovementRanker(RankerBase): - # TODO Implement this (in another PR) - pass + """Ranks the solutions based on the improvement in the objective. + + This ranker ranks solutions in a single stage. The solutions are ranked by + the improvement "value" described in :meth:`ArchiveBase.add`. + """ + + def rank(self, emitter, archive, rng, solution_batch, objective_batch, + measures_batch, status_batch, value_batch, metadata_batch): + # Note that lexsort sorts the values in ascending order, + # so we use np.flip to reverse the sorted array. + return np.flip(np.argsort(value_batch)), value_batch + + rank.__doc__ = f""" +Generates a list of indices that represents an ordering of solutions. + +{_rank_args} + """ class TwoStageImprovementRanker(RankerBase): diff --git a/ribs/schedulers/_scheduler.py b/ribs/schedulers/_scheduler.py index e14016bc4..bb3ddaf0b 100644 --- a/ribs/schedulers/_scheduler.py +++ b/ribs/schedulers/_scheduler.py @@ -1,6 +1,5 @@ """Provides the Scheduler.""" import numpy as np - from ribs.emitters import DQDEmitterBase @@ -36,6 +35,10 @@ class Scheduler: included for legacy reasons, as it was the only mode of operation in pyribs 0.4.0 and before. We highly recommend using "batch" mode since it is significantly faster. + result_archive (ribs.archives.ArchiveBase): In some algorithms, such as + CMA-MAE, the archive does not store all the best-performing + solutions. The `result_archive` is a secondary archive where we can + store all the best-performing solutions. Raises: ValueError: The emitters passed in do not have the same solution dimensions. @@ -45,7 +48,11 @@ class Scheduler: ValueError: Invalid value for `add_mode`. """ - def __init__(self, archive, emitters, add_mode="batch"): + def __init__(self, + archive, + emitters, + result_archive=None, + add_mode="batch"): if len(emitters) == 0: raise ValueError("Pass in at least one emitter to the scheduler.") @@ -71,10 +78,18 @@ def __init__(self, archive, emitters, add_mode="batch"): raise ValueError("add_mode must either be 'batch' or 'single', but " f"it was '{add_mode}'") + if archive is result_archive: + raise ValueError("`archive` has same id as `result_archive` -- " + "Note that `Scheduler.result_archive` already " + "defaults to be the same as `archive` if you pass " + "`result_archive=None`") + self._archive = archive self._emitters = emitters self._add_mode = add_mode + self._result_archive = result_archive + # Keeps track of whether the scheduler should be receiving a call to # ask() or tell(). self._last_called = None @@ -95,6 +110,16 @@ def emitters(self): in this scheduler.""" return self._emitters + @property + def result_archive(self): + """ribs.archives.ArchiveBase: Another archive for storing solutions + found in this optimizer. + If `result_archive` was not passed to the constructor, this property is + the same as :attr:`archive`. + """ + return (self._archive + if self._result_archive is None else self._result_archive) + def ask_dqd(self): """Generates a batch of solutions by calling ask_dqd() on all DQD emitters. @@ -122,7 +147,7 @@ def ask_dqd(self): self._solution_batch.append(emitter_sols) self._num_emitted[i] = len(emitter_sols) - # In case the emitters didn't return any solutions + # In case the emitters didn't return any solutions. self._solution_batch = np.concatenate( self._solution_batch, axis=0) if self._solution_batch else np.empty( (0, self._solution_dim)) @@ -153,7 +178,7 @@ def ask(self): self._solution_batch.append(emitter_sols) self._num_emitted[i] = len(emitter_sols) - # In case the emitters didn't return any solutions + # In case the emitters didn't return any solutions. self._solution_batch = np.concatenate( self._solution_batch, axis=0) if self._solution_batch else np.empty( (0, self._solution_dim)) @@ -192,20 +217,26 @@ def _tell_internal(self, measures_batch, metadata_batch, ) + + # Add solutions to result_archive. + if self._result_archive is not None: + self._result_archive.add(self._solution_batch, objective_batch, + measures_batch, metadata_batch) elif self._add_mode == "single": - status_batch, value_batch = zip(*[ - self.archive.add_single( - solution, - objective, - measure, - metadata, - ) for solution, objective, measure, metadata in zip( - self._solution_batch, - objective_batch, - measures_batch, - metadata_batch, - ) - ]) + status_batch = [] + value_batch = [] + for solution, objective, measure, metadata in zip( + self._solution_batch, objective_batch, measures_batch, + metadata_batch): + status, value = self.archive.add_single(solution, objective, + measure, metadata) + status_batch.append(status) + value_batch.append(value) + + # Add solutions to result_archive. + if self._result_archive is not None: + self._result_archive.add_single(solution, objective, + measure, metadata) status_batch = np.asarray(status_batch) value_batch = np.asarray(value_batch) diff --git a/tests/archives/archive_base_test.py b/tests/archives/archive_base_test.py index 13847e034..ee0c7313b 100644 --- a/tests/archives/archive_base_test.py +++ b/tests/archives/archive_base_test.py @@ -46,15 +46,20 @@ def test_iteration(): assert elite.metadata == data.metadata -def test_add_during_iteration(): +def test_add_during_iteration(add_mode): # Even with just one entry, adding during iteration should still raise an # error, just like it does in set. data = get_archive_data("GridArchive") with pytest.raises(RuntimeError): for _ in data.archive_with_elite: - data.archive_with_elite.add_single(data.solution, - data.objective + 1, - data.measures) + if add_mode == "single": + data.archive_with_elite.add_single(data.solution, + data.objective + 1, + data.measures) + else: + data.archive_with_elite.add([data.solution], + [data.objective + 1], + [data.measures]) def test_clear_during_iteration(): @@ -64,14 +69,19 @@ def test_clear_during_iteration(): data.archive_with_elite.clear() -def test_clear_and_add_during_iteration(): +def test_clear_and_add_during_iteration(add_mode): data = get_archive_data("GridArchive") with pytest.raises(RuntimeError): for _ in data.archive_with_elite: data.archive_with_elite.clear() - data.archive_with_elite.add_single(data.solution, - data.objective + 1, - data.measures) + if add_mode == "single": + data.archive_with_elite.add_single(data.solution, + data.objective + 1, + data.measures) + else: + data.archive_with_elite.add([data.solution], + [data.objective + 1], + [data.measures]) # @@ -90,13 +100,19 @@ def test_stats_dtype(dtype): assert isinstance(data.archive_with_elite.stats.obj_mean, dtype) -def test_stats_multiple_add(): +def test_stats_multiple_add(add_mode): archive = GridArchive(solution_dim=3, dims=[10, 20], ranges=[(-1, 1), (-2, 2)]) - archive.add_single([1, 2, 3], 1.0, [0, 0]) - archive.add_single([1, 2, 3], 2.0, [0.25, 0.25]) - archive.add_single([1, 2, 3], 3.0, [-0.25, -0.25]) + if add_mode == "single": + archive.add_single([1, 2, 3], 1.0, [0, 0]) + archive.add_single([1, 2, 3], 2.0, [0.25, 0.25]) + archive.add_single([1, 2, 3], 3.0, [-0.25, -0.25]) + else: + solution_batch = [[1, 2, 3]] * 3 + objective_batch = [1.0, 2.0, 3.0] + measures_batch = [[0, 0], [0.25, 0.25], [-0.25, -0.25]] + archive.add(solution_batch, objective_batch, measures_batch) assert archive.stats.num_elites == 3 assert np.isclose(archive.stats.coverage, 3 / 200) @@ -105,15 +121,21 @@ def test_stats_multiple_add(): assert np.isclose(archive.stats.obj_mean, 2.0) -def test_stats_add_and_overwrite(): +def test_stats_add_and_overwrite(add_mode): archive = GridArchive(solution_dim=3, dims=[10, 20], ranges=[(-1, 1), (-2, 2)]) - archive.add_single([1, 2, 3], 1.0, [0, 0]) - archive.add_single([1, 2, 3], 2.0, [0.25, 0.25]) - archive.add_single([1, 2, 3], 3.0, [-0.25, -0.25]) - archive.add_single([1, 2, 3], 5.0, - [0.25, 0.25]) # Overwrites the second add. + if add_mode == "single": + archive.add_single([1, 2, 3], 1.0, [0, 0]) + archive.add_single([1, 2, 3], 2.0, [0.25, 0.25]) + archive.add_single([1, 2, 3], 3.0, [-0.25, -0.25]) + archive.add_single([1, 2, 3], 5.0, + [0.25, 0.25]) # Overwrites the second add. + else: + solution_batch = [[1, 2, 3]] * 4 + objective_batch = [1.0, 2.0, 3.0, 5.0] + measures_batch = [[0, 0], [0.25, 0.25], [-0.25, -0.25], [0.25, 0.25]] + archive.add(solution_batch, objective_batch, measures_batch) assert archive.stats.num_elites == 3 assert np.isclose(archive.stats.coverage, 3 / 200) @@ -122,7 +144,7 @@ def test_stats_add_and_overwrite(): assert np.isclose(archive.stats.obj_mean, 3.0) -def test_best_elite(): +def test_best_elite(add_mode): archive = GridArchive(solution_dim=3, dims=[10, 20], ranges=[(-1, 1), (-2, 2)]) @@ -131,7 +153,10 @@ def test_best_elite(): assert archive.best_elite is None # Add an elite. - archive.add_single([1, 2, 3], 1.0, [0, 0]) + if add_mode == "single": + archive.add_single([1, 2, 3], 1.0, [0, 0]) + else: + archive.add([[1, 2, 3]], [1.0], [[0, 0]]) assert np.isclose(archive.best_elite.solution, [1, 2, 3]).all() assert np.isclose(archive.best_elite.objective, 1.0).all() @@ -139,7 +164,10 @@ def test_best_elite(): # Add an elite into the same cell as the previous elite -- best_elite should # now be overwritten. - archive.add_single([4, 5, 6], 2.0, [0, 0]) + if add_mode == "single": + archive.add_single([4, 5, 6], 2.0, [0, 0]) + else: + archive.add([[4, 5, 6]], [2.0], [[0, 0]]) assert np.isclose(archive.best_elite.solution, [4, 5, 6]).all() assert np.isclose(archive.best_elite.objective, 2.0).all() @@ -208,6 +236,14 @@ def test_solution_dim_correct(data): assert data.archive.solution_dim == len(data.solution) +def test_learning_rate_correct(data): + assert data.archive.learning_rate == 1.0 # Default value. + + +def test_threshold_min_correct(data): + assert data.archive.threshold_min == -np.inf # Default value. + + def test_basic_stats(data): assert data.archive.stats.num_elites == 0 assert data.archive.stats.coverage == 0.0 diff --git a/tests/archives/archive_threshold_update_test.py b/tests/archives/archive_threshold_update_test.py new file mode 100644 index 000000000..1a243eb92 --- /dev/null +++ b/tests/archives/archive_threshold_update_test.py @@ -0,0 +1,119 @@ +"""Tests for theshold update in archive.""" +import numpy as np +import pytest + +from ribs.archives import GridArchive + +from .conftest import get_archive_data + +# pylint: disable = redefined-outer-name + + +@pytest.fixture +def data(): + """Data for grid archive tests.""" + return get_archive_data("GridArchive") + + +def update_threshold(threshold, f_val, learning_rate): + return (1.0 - learning_rate) * threshold + learning_rate * f_val + + +def calc_geom(base_value, exponent): + if base_value == 1.0: + return exponent + top = 1 - base_value**exponent + bottom = 1 - base_value + return top / bottom + + +def calc_expected_threshold(additions, cell_value, learning_rate): + k = len(additions) + geom = calc_geom(1.0 - learning_rate, k) + f_star = sum(additions) + term1 = learning_rate * f_star * geom / k + term2 = cell_value * (1.0 - learning_rate)**k + return term1 + term2 + + +@pytest.mark.parametrize("learning_rate", [0, 0.001, 0.01, 0.1, 1]) +def test_threshold_update_for_one_cell(data, learning_rate): + archive = data.archive + + threshold_arr = np.array([-3.1]) + objective_batch = np.array([0.1, 0.3, 0.9, 400.0, 42.0]) + index_batch = np.array([0, 0, 0, 0, 0]) + + # pylint: disable = protected-access + result_test, _ = archive._compute_new_thresholds(threshold_arr, + objective_batch, + index_batch, learning_rate) + result_true = calc_expected_threshold(objective_batch, threshold_arr[0], + learning_rate) + + assert pytest.approx(result_test[0]) == result_true + + +@pytest.mark.parametrize("learning_rate", [0, 0.001, 0.01, 0.1, 1]) +def test_threshold_update_for_multiple_cells(data, learning_rate): + archive = data.archive + + threshold_arr = np.array([-3.1, 0.4, 2.9]) + objective_batch = np.array([ + 0.1, 0.3, 0.9, 400.0, 42.0, 0.44, 0.53, 0.51, 0.80, 0.71, 33.6, 61.78, + 81.71, 83.48, 41.18 + ]) + index_batch = np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2]) + + # pylint: disable = protected-access + result_test, _ = archive._compute_new_thresholds(threshold_arr, + objective_batch, + index_batch, learning_rate) + + result_true = [ + calc_expected_threshold(objective_batch[5 * i:5 * (i + 1)], + threshold_arr[i], learning_rate) + for i in range(3) + ] + + assert np.all(np.isclose(result_test, result_true)) + + +def test_threshold_update_for_empty_objective_and_index(data): + archive = data.archive + + threshold_arr = np.array([-3.1, 0.4, 2.9]) + objective_batch = np.array([]) # Empty objective. + index_batch = np.array([]) # Empty index. + + # pylint: disable = protected-access + new_threshold_batch, threshold_update_indices = ( + archive._compute_new_thresholds(threshold_arr, objective_batch, + index_batch, 0.1)) + + assert new_threshold_batch.size == 0 + assert threshold_update_indices.size == 0 + + +def test_init_learning_rate_and_threshold_min(): + # Setting threshold_min while not setting the learning_rate should not + # raise an error. + _ = GridArchive(solution_dim=2, + dims=[10, 20], + ranges=[(-1, 1), (-2, 2)], + threshold_min=0) + + # Setting both learning_rate and threshold_min should not raise an error. + _ = GridArchive(solution_dim=2, + dims=[10, 20], + ranges=[(-1, 1), (-2, 2)], + learning_rate=0.1, + threshold_min=0) + + # Setting learning_rate while not setting the threshold_min should raise an + # error. + with pytest.raises(ValueError): + _ = GridArchive(solution_dim=2, + dims=[10, 20], + ranges=[(-1, 1), (-2, 2)], + learning_rate=0.1) diff --git a/tests/archives/conftest.py b/tests/archives/conftest.py index ce471f206..71ff553b8 100644 --- a/tests/archives/conftest.py +++ b/tests/archives/conftest.py @@ -31,6 +31,12 @@ def use_kd_tree(request): return request.param +@pytest.fixture(params=["single", "batch"]) +def add_mode(request): + """Single or batch add.""" + return request.param + + # # Helpers for generating archive data. # @@ -71,7 +77,7 @@ def get_archive_data(name, dtype=np.float64): ARCHIVE_NAMES. """ # Characteristics of a single solution to insert into archive_with_elite. - solution = np.array([1, 2, 3]) + solution = np.array([1., 2., 3.]) objective = 1.0 measures = np.array([0.25, 0.25]) metadata = {"metadata_key": 42} diff --git a/tests/archives/cvt_archive_test.py b/tests/archives/cvt_archive_test.py index 46a6f5e26..b45afac81 100644 --- a/tests/archives/cvt_archive_test.py +++ b/tests/archives/cvt_archive_test.py @@ -73,15 +73,22 @@ def test_custom_centroids_bad_shape(use_kd_tree): @pytest.mark.parametrize("use_list", [True, False], ids=["list", "ndarray"]) -def test_add_single_to_archive(data, use_list): +def test_add_single_to_archive(data, use_list, add_mode): + solution = data.solution + objective = data.objective + measures = data.measures + metadata = data.metadata + if use_list: - status, value = data.archive.add_single(list(data.solution), - data.objective, - list(data.measures), - data.metadata) + solution = list(data.solution) + measures = list(data.measures) + + if add_mode == "single": + status, value = data.archive.add_single(solution, objective, measures, + metadata) else: - status, value = data.archive.add_single(data.solution, data.objective, - data.measures, data.metadata) + status, value = data.archive.add([solution], [objective], [measures], + [metadata]) assert status == AddStatus.NEW assert np.isclose(value, data.objective) @@ -89,32 +96,42 @@ def test_add_single_to_archive(data, use_list): data.measures, data.centroid, data.metadata) -def test_add_single_and_overwrite(data): +def test_add_single_and_overwrite(data, add_mode): """Test adding a new solution with a higher objective value.""" arbitrary_sol = data.solution + 1 arbitrary_metadata = {"foobar": 12} high_objective = data.objective + 1.0 - status, value = data.archive_with_elite.add_single(arbitrary_sol, - high_objective, - data.measures, - arbitrary_metadata) + if add_mode == "single": + status, value = data.archive_with_elite.add_single( + arbitrary_sol, high_objective, data.measures, arbitrary_metadata) + else: + status, value = data.archive_with_elite.add([arbitrary_sol], + [high_objective], + [data.measures], + [arbitrary_metadata]) + assert status == AddStatus.IMPROVE_EXISTING assert np.isclose(value, high_objective - data.objective) assert_archive_elite(data.archive_with_elite, arbitrary_sol, high_objective, data.measures, data.centroid, arbitrary_metadata) -def test_add_single_without_overwrite(data): +def test_add_single_without_overwrite(data, add_mode): """Test adding a new solution with a lower objective value.""" arbitrary_sol = data.solution + 1 arbitrary_metadata = {"foobar": 12} low_objective = data.objective - 1.0 - status, value = data.archive_with_elite.add_single(arbitrary_sol, - low_objective, - data.measures, - arbitrary_metadata) + if add_mode == "single": + status, value = data.archive_with_elite.add_single( + arbitrary_sol, low_objective, data.measures, arbitrary_metadata) + else: + status, value = data.archive_with_elite.add([arbitrary_sol], + [low_objective], + [data.measures], + [arbitrary_metadata]) + assert status == AddStatus.NOT_ADDED assert np.isclose(value, low_objective - data.objective) assert_archive_elite(data.archive_with_elite, data.solution, data.objective, diff --git a/tests/archives/grid_archive_test.py b/tests/archives/grid_archive_test.py index 7d57ed545..bba09d743 100644 --- a/tests/archives/grid_archive_test.py +++ b/tests/archives/grid_archive_test.py @@ -104,15 +104,22 @@ def test_properties_are_correct(data): @pytest.mark.parametrize("use_list", [True, False], ids=["list", "ndarray"]) -def test_add_single_to_archive(data, use_list): +def test_add_single_to_archive(data, use_list, add_mode): + solution = data.solution + objective = data.objective + measures = data.measures + metadata = data.metadata + if use_list: - status, value = data.archive.add_single(list(data.solution), - data.objective, - list(data.measures), - data.metadata) + solution = list(data.solution) + measures = list(data.measures) + + if add_mode == "single": + status, value = data.archive.add_single(solution, objective, measures, + metadata) else: - status, value = data.archive.add_single(data.solution, data.objective, - data.measures, data.metadata) + status, value = data.archive.add([solution], [objective], [measures], + [metadata]) assert status == AddStatus.NEW assert np.isclose(value, data.objective) @@ -120,58 +127,162 @@ def test_add_single_to_archive(data, use_list): data.measures, data.grid_indices, data.metadata) -def test_add_single_with_low_measures(data): +@pytest.mark.parametrize("use_list", [True, False], ids=["list", "ndarray"]) +def test_add_single_to_archive_negative_objective(data, use_list, add_mode): + """Same as test_add_single_to_archive, but negative objective since there + are some weird cases when handling value calculations.""" + solution = data.solution + objective = -data.objective + measures = data.measures + metadata = data.metadata + + if use_list: + solution = list(data.solution) + measures = list(data.measures) + + if add_mode == "single": + status, value = data.archive.add_single(solution, objective, measures, + metadata) + else: + status, value = data.archive.add([solution], [objective], [measures], + [metadata]) + + assert status == AddStatus.NEW + assert np.isclose(value, -data.objective) + assert_archive_elite(data.archive_with_elite, data.solution, data.objective, + data.measures, data.grid_indices, data.metadata) + + +def test_add_single_with_low_measures(data, add_mode): measures = np.array([-2, -3]) indices = (0, 0) - status, _ = data.archive.add_single(data.solution, data.objective, measures, - data.metadata) + if add_mode == "single": + status, _ = data.archive.add_single(data.solution, data.objective, + measures, data.metadata) + else: + status, _ = data.archive.add([data.solution], [data.objective], + [measures], [data.metadata]) + assert status assert_archive_elite(data.archive, data.solution, data.objective, measures, indices, data.metadata) -def test_add_single_with_high_measures(data): +def test_add_single_with_high_measures(data, add_mode): measures = np.array([2, 3]) indices = (9, 19) - status, _ = data.archive.add_single(data.solution, data.objective, measures, - data.metadata) + if add_mode == "single": + status, _ = data.archive.add_single(data.solution, data.objective, + measures, data.metadata) + else: + status, _ = data.archive.add([data.solution], [data.objective], + [measures], [data.metadata]) assert status assert_archive_elite(data.archive, data.solution, data.objective, measures, indices, data.metadata) -def test_add_single_and_overwrite(data): +def test_add_single_and_overwrite(data, add_mode): """Test adding a new solution with a higher objective value.""" arbitrary_sol = data.solution + 1 arbitrary_metadata = {"foobar": 12} high_objective = data.objective + 1.0 - status, value = data.archive_with_elite.add_single(arbitrary_sol, - high_objective, - data.measures, - arbitrary_metadata) + if add_mode == "single": + status, value = data.archive_with_elite.add_single( + arbitrary_sol, high_objective, data.measures, arbitrary_metadata) + else: + status, value = data.archive_with_elite.add([arbitrary_sol], + [high_objective], + [data.measures], + [arbitrary_metadata]) + assert status == AddStatus.IMPROVE_EXISTING assert np.isclose(value, high_objective - data.objective) assert_archive_elite(data.archive_with_elite, arbitrary_sol, high_objective, data.measures, data.grid_indices, arbitrary_metadata) -def test_add_single_without_overwrite(data): +def test_add_single_without_overwrite(data, add_mode): """Test adding a new solution with a lower objective value.""" arbitrary_sol = data.solution + 1 arbitrary_metadata = {"foobar": 12} low_objective = data.objective - 1.0 - status, value = data.archive_with_elite.add_single(arbitrary_sol, - low_objective, - data.measures, - arbitrary_metadata) + if add_mode == "single": + status, value = data.archive_with_elite.add_single( + arbitrary_sol, low_objective, data.measures, arbitrary_metadata) + else: + status, value = data.archive_with_elite.add([arbitrary_sol], + [low_objective], + [data.measures], + [arbitrary_metadata]) + assert status == AddStatus.NOT_ADDED assert np.isclose(value, low_objective - data.objective) assert_archive_elite(data.archive_with_elite, data.solution, data.objective, data.measures, data.grid_indices, data.metadata) +def test_add_single_threshold_update(add_mode): + archive = GridArchive( + solution_dim=3, + dims=[10, 10], + ranges=[(-1, 1), (-1, 1)], + threshold_min=-1.0, + learning_rate=0.1, + ) + solution = [1, 2, 3] + measures = [0.1, 0.1] + + # Add a new solution to the archive. + if add_mode == "single": + status, value = archive.add_single(solution, 0.0, measures) + else: + status_batch, value_batch = archive.add([solution], [0.0], [measures]) + status, value = status_batch[0], value_batch[0] + + assert status == 2 # NEW + assert np.isclose(value, 1.0) # 0.0 - (-1.0) + + # Threshold should now be (1 - 0.1) * -1.0 + 0.1 * 0.0 = -0.9 + + # These solutions are below the threshold and should not be inserted. + if add_mode == "single": + status, value = archive.add_single(solution, -0.95, measures) + else: + status_batch, value_batch = archive.add([solution], [-0.95], [measures]) + status, value = status_batch[0], value_batch[0] + + assert status == 0 # NOT_ADDED + assert np.isclose(value, -0.05) # -0.95 - (-0.9) + + # These solutions are above the threshold and should be inserted. + if add_mode == "single": + status, value = archive.add_single(solution, -0.8, measures) + else: + status_batch, value_batch = archive.add([solution], [-0.8], [measures]) + status, value = status_batch[0], value_batch[0] + + assert status == 1 # IMPROVE_EXISTING + assert np.isclose(value, 0.1) # -0.8 - (-0.9) + + +def test_add_single_wrong_shapes(data): + with pytest.raises(ValueError): + data.archive.add_single( + solution=[1, 1], # 2D instead of 3D solution. + objective=0, + measures=[0, 0], + ) + with pytest.raises(ValueError): + data.archive.add_single( + solution=[0, 0, 0], + objective=0, + measures=[1, 1, 1], # 3D instead of 2D measures. + ) + + def test_add_batch_all_new(data): status_batch, value_batch = data.archive.add( # 4 solutions of arbitrary value. @@ -319,19 +430,134 @@ def test_add_batch_first_solution_wins_in_ties(data): ) -def test_add_single_wrong_shapes(data): - with pytest.raises(ValueError): - data.archive.add_single( - solution=[1, 1], # 2D instead of 3D solution. - objective=0, - measures=[0, 0], - ) - with pytest.raises(ValueError): - data.archive.add_single( - solution=[0, 0, 0], - objective=0, - measures=[1, 1, 1], # 3D instead of 2D measures. - ) +def test_add_batch_not_inserted_if_below_threshold_min(): + archive = GridArchive( + solution_dim=3, + dims=[10, 10], + ranges=[(-1, 1), (-1, 1)], + threshold_min=-10.0, + learning_rate=0.1, + ) + + status_batch, value_batch = archive.add( + solution_batch=[[1, 2, 3]] * 4, + objective_batch=[-20.0, -20.0, 10.0, 10.0], + measures_batch=[[0.0, 0.0]] * 4, + ) + + # The first two solutions should not have been inserted since they did not + # cross the threshold_min of -10.0. + assert (status_batch == [0, 0, 2, 2]).all() + assert np.isclose(value_batch, [-10.0, -10.0, 20.0, 20.0]).all() + + assert_archive_elite_batch( + archive=archive, + batch_size=1, + solution_batch=[[1, 2, 3]], + objective_batch=[10.0], + measures_batch=[[0.0, 0.0]], + metadata_batch=[None], + grid_indices_batch=[[5, 5]], + ) + + +def test_add_batch_threshold_update(): + archive = GridArchive( + solution_dim=3, + dims=[10, 10], + ranges=[(-1, 1), (-1, 1)], + threshold_min=-1.0, + learning_rate=0.1, + ) + solution = [1, 2, 3] + measures = [0.1, 0.1] + measures2 = [-0.1, -0.1] + + # Add new solutions to the archive in two cells determined by measures and + # measures2. + status_batch, value_batch = archive.add( + [solution, solution, solution, solution, solution, solution], + # The first three solutions are inserted since they cross + # threshold_min, but the last solution is not inserted since it does not + # cross threshold_min. + [0.0, 1.0, 2.0, 10.0, 100.0, -10.0], + [measures, measures, measures, measures2, measures2, measures2], + ) + + assert (status_batch == [2, 2, 2, 2, 2, 0]).all() + assert np.isclose( + value_batch, [1.0, 2.0, 3.0, 11.0, 101.0, -9.0]).all() # [...] - (-1.0) + + # Thresholds based on batch update rule should now be + # (1 - 0.1)**3 * -1.0 + (0.0 + 1.0 + 2.0) / 3 * (1 - (1 - 0.1)**3) = -0.458 + # and + # (1 - 0.1)**2 * -1.0 + (10.0 + 100.0) / 2 * (1 - (1 - 0.1)**2) = 9.64 + + # Mix between solutions which are inserted and not inserted. + status_batch, value_batch = archive.add( + [solution, solution, solution, solution], + [-0.95, -0.457, 9.63, 9.65], + [measures, measures, measures2, measures2], + ) + + assert (status_batch == [0, 1, 0, 1]).all() + # [-0.95 - (-0.458), -0.458 - (-0.457), 9.63 - 9.64, 9.65 - 9.64] + assert np.isclose(value_batch, [-0.492, 0.001, -0.01, 0.01]).all() + + # Thresholds should now be + # (1 - 0.1)**1 * -0.458 + (-0.457) / 1 * (1 - (1 - 0.1)**1) = -0.4579 + # and + # (1 - 0.1)**1 * 9.64 + (9.65) / 1 * (1 - (1 - 0.1)**1) = 9.641 + + # Again mix between solutions which are inserted and not inserted. + status_batch, value_batch = archive.add( + [solution, solution, solution, solution], + [-0.4580, -0.4578, 9.640, 9.642], + [measures, measures, measures2, measures2], + ) + + assert (status_batch == [0, 1, 0, 1]).all() + assert np.isclose(value_batch, [-0.0001, 0.0001, -0.001, 0.001]).all() + + +def test_add_batch_threshold_update_inf_threshold_min(): + # These default values of threshold_min and learning_rate induce special + # CMA-ME behavior for threshold updates. + archive = GridArchive( + solution_dim=3, + dims=[10, 10], + ranges=[(-1, 1), (-1, 1)], + threshold_min=-np.inf, + learning_rate=1.0, + ) + solution = [1, 2, 3] + measures = [0.1, 0.1] + measures2 = [-0.1, -0.1] + + # Add new solutions to the archive. + status_batch, value_batch = archive.add( + [solution, solution, solution, solution, solution, solution], + [0.0, 1.0, 2.0, -10.0, 10.0, 100.0], + [measures, measures, measures, measures2, measures2, measures2], + ) + + # Value is same as objective since these are new cells. + assert (status_batch == [2, 2, 2, 2, 2, 2]).all() + assert np.isclose(value_batch, [0.0, 1.0, 2.0, -10.0, 10.0, 100.0]).all() + + # Thresholds are updated based on maximum values in each cell, i.e. 2.0 and + # 100.0. + + # Mix between solutions which are inserted and not inserted. + status_batch, value_batch = archive.add( + [solution, solution, solution, solution], + [1.0, 10.0, 99.0, 101.0], + [measures, measures, measures2, measures2], + ) + + assert (status_batch == [0, 1, 0, 1]).all() + # [1.0 - 2.0, 10.0 - 2.0, 99.0 - 100.0, 101.0 - 100.0] + assert np.isclose(value_batch, [-1.0, 8.0, -1.0, 1.0]).all() def test_add_batch_wrong_shapes(data): @@ -449,3 +675,21 @@ def test_values_go_to_correct_bin(dtype): # Upper bound and above belong in last bin. assert archive.index_of_single([0.1]) == 9 assert archive.index_of_single([0.11]) == 9 + + +def test_nonfinite_inputs(data): + data.solution[0] = np.inf + data.measures[0] = np.nan + + with pytest.raises(ValueError): + data.archive.add([data.solution], -np.inf, [data.measures]) + with pytest.raises(ValueError): + data.archive.add_single(data.solution, -np.inf, data.measures) + with pytest.raises(ValueError): + data.archive.elites_with_measures([data.measures]) + with pytest.raises(ValueError): + data.archive.elites_with_measures_single(data.measures) + with pytest.raises(ValueError): + data.archive.index_of([data.measures]) + with pytest.raises(ValueError): + data.archive.index_of_single(data.measures) diff --git a/tests/examples.sh b/tests/examples.sh index 65c741d3d..3ff0fb88c 100644 --- a/tests/examples.sh +++ b/tests/examples.sh @@ -19,18 +19,27 @@ fi export OPENBLAS_NUM_THREADS=1 export OMP_NUM_THREADS=1 -# sphere.py - CVT excluded since it takes a while to build the archive. +# sphere.py SPHERE_OUTPUT="${TMPDIR}/sphere_output" -python examples/sphere.py map_elites 20 10 "${SPHERE_OUTPUT}" -python examples/sphere.py line_map_elites 20 10 "${SPHERE_OUTPUT}" -# python examples/sphere.py cvt_map_elites 20 10 "${SPHERE_OUTPUT}" -# python examples/sphere.py line_cvt_map_elites 20 10 "${SPHERE_OUTPUT}" -python examples/sphere.py cma_me_imp 20 10 "${SPHERE_OUTPUT}" -python examples/sphere.py cma_me_imp_mu 20 10 "${SPHERE_OUTPUT}" -python examples/sphere.py cma_me_rd 20 10 "${SPHERE_OUTPUT}" -python examples/sphere.py cma_me_rd_mu 20 10 "${SPHERE_OUTPUT}" -python examples/sphere.py cma_me_opt 20 10 "${SPHERE_OUTPUT}" -python examples/sphere.py cma_me_mixed 20 10 "${SPHERE_OUTPUT}" +python examples/sphere.py map_elites --itrs 10 --outdir "${SPHERE_OUTPUT}" +python examples/sphere.py line_map_elites --itrs 10 --outdir "${SPHERE_OUTPUT}" + +# CVT excluded since it takes a while to build the archive. +# python examples/sphere.py cvt_map_elites 10 "${SPHERE_OUTPUT}" +# python examples/sphere.py line_cvt_map_elites 10 "${SPHERE_OUTPUT}" + +python examples/sphere.py cma_me_imp --itrs 10 --outdir "${SPHERE_OUTPUT}" +python examples/sphere.py cma_me_imp_mu --itrs 10 --outdir "${SPHERE_OUTPUT}" +python examples/sphere.py cma_me_rd --itrs 10 --outdir "${SPHERE_OUTPUT}" +python examples/sphere.py cma_me_rd_mu --itrs 10 --outdir "${SPHERE_OUTPUT}" +python examples/sphere.py cma_me_opt --itrs 10 --outdir "${SPHERE_OUTPUT}" +python examples/sphere.py cma_me_mixed --itrs 10 --outdir "${SPHERE_OUTPUT}" + +python examples/sphere.py cma_mega --dim 20 --itrs 10 --outdir "${SPHERE_OUTPUT}" +python examples/sphere.py cma_mega_adam --dim 20 --itrs 10 --outdir "${SPHERE_OUTPUT}" + +python examples/sphere.py cma_mae --dim 20 --itrs 10 --learning_rate 0.01 --outdir "${SPHERE_OUTPUT}" +python examples/sphere.py cma_maega --dim 20 --itrs 10 --learning_rate 0.01 --outdir "${SPHERE_OUTPUT}" # lunar_lander.py LUNAR_LANDER_OUTPUT="${TMPDIR}/lunar_lander_output" diff --git a/tests/schedulers/scheduler_test.py b/tests/schedulers/scheduler_test.py index f7b5ce905..278c1d77e 100644 --- a/tests/schedulers/scheduler_test.py +++ b/tests/schedulers/scheduler_test.py @@ -23,6 +23,12 @@ def scheduler_fixture(): return Scheduler(archive, emitters), solution_dim, num_solutions +@pytest.fixture(params=["single", "batch"]) +def add_mode(request): + """Single or batch add.""" + return request.param + + def test_init_fails_with_no_emitters(): # arbitrary sol_dim archive = GridArchive(10, [100, 100], [(-1, 1), (-1, 1)]) @@ -69,8 +75,6 @@ def test_ask_fails_when_called_twice(scheduler_fixture): scheduler.ask() -@pytest.mark.parametrize("add_mode", ["batch", "single"], - ids=["batch_add", "single_add"]) @pytest.mark.parametrize("tell_metadata", [True, False], ids=["metadata", "no_metadata"]) def test_tell_inserts_solutions_into_archive(add_mode, tell_metadata): @@ -98,8 +102,6 @@ def test_tell_inserts_solutions_into_archive(add_mode, tell_metadata): ) -@pytest.mark.parametrize("add_mode", ["batch", "single"], - ids=["batch_add", "single_add"]) @pytest.mark.parametrize("tell_metadata", [True, False], ids=["metadata", "no_metadata"]) def test_tell_inserts_solutions_with_multiple_emitters(add_mode, tell_metadata):