From e337131d802e9f1a65c5486008802aa00ccf4822 Mon Sep 17 00:00:00 2001 From: amarrerod Date: Wed, 3 Jun 2026 15:47:44 +0100 Subject: [PATCH 01/13] Implementes novelty_thresholds decay after X iterations without inserting new solutions in ProximityArchive --- HISTORY.md | 1 + README.md | 1 + ribs/archives/_proximity_archive.py | 60 ++++++++++++++++++++++++ tests/archives/proximity_archive_test.py | 25 ++++++++++ 4 files changed, 87 insertions(+) diff --git a/HISTORY.md b/HISTORY.md index 01db6e6db..2388202c3 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -15,6 +15,7 @@ - Add `DDS-CNF` density method to `DensityArchive` ({pr}`691`, {pr}`707`) - Add PyTorch to ribs[all] deps ({pr}`692`) - Add `NSLCRanker` for Novelty Search with Local Competition ({pr}`690`) +- Implements `novelty_threshold` decay in `ProximityArchive`({issue}`540`, {pr}`708`) #### Documentation diff --git a/README.md b/README.md index c99f5c28a..2405ef844 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,7 @@ USC. For information on contributing to the repo, see - [Efstathios Siatras](https://siatras.com/) - [Milo Brontesi](https://github.com/zibasPk) - [Stefanos Nikolaidis](https://stefanosnikolaidis.net) +- [Alejandro Marrero](https://github.com/amarrerod) We thank [Amy K. Hoover](http://amykhoover.com/) and [Julian Togelius](http://julian.togelius.com/) for their contributions deriving diff --git a/ribs/archives/_proximity_archive.py b/ribs/archives/_proximity_archive.py index 0f8dcd5f0..c4fee60f5 100644 --- a/ribs/archives/_proximity_archive.py +++ b/ribs/archives/_proximity_archive.py @@ -102,6 +102,16 @@ class ProximityArchive(ArchiveBase): *subtracted* from all objectives in the archive, e.g., if your objectives go as low as -300, pass in -300 so that each objective will be transformed as ``objective - (-300)``. + iterations_without_imp: Maximum number of iterations without inserting novel + solutions, before decreasing the ``novelty_threshold`` by a factor of + ``threshold_decay``. This is an optional parameter that by + default is set to None, which means that the ``novelty_threshold`` + is not updated during the evolution. + threshold_decay: Decay factor to reduce the ``novelty_treshold`` if there were + ``iterations_without_imp`` iterations without inserting novelty solutions. + This is a optional float value in the range [0.0, 1.0]. Default is set to + None, which means that the ``novelty_threshold`` is not updated during + the evolution. seed: Value to seed the random number generator. Set to None to avoid a fixed seed. solution_dtype: Data type of the solutions. Defaults to float64 (numpy's default @@ -140,6 +150,8 @@ def __init__( local_competition: bool = False, initial_capacity: Int = 128, qd_score_offset: Float = 0.0, + iterations_without_imp: Int | None = None, + threshold_decay: Float | None = None, seed: Int | None = None, solution_dtype: DTypeLike = None, objective_dtype: DTypeLike = None, @@ -179,6 +191,24 @@ def __init__( solution_dtype, objective_dtype, measures_dtype = parse_all_dtypes( dtype, solution_dtype, objective_dtype, measures_dtype, np ) + + # By default, threshold decay is not defined. + self._max_it_without_imp = None + if iterations_without_imp is not None: + if iterations_without_imp < 0: + raise ValueError( + "iterations_without_imp must be either None or a positive integer." + ) + if threshold_decay is None or ( + threshold_decay < 0.0 or threshold_decay > 1.0 + ): + raise ValueError( + "If iterations_without_imp is not None, threshold decay must be a float in the range [0.0, 1.0]." + ) + self._max_it_without_imp = int(iterations_without_imp) + self._threshold_decay = float(threshold_decay) + self._its_without_imp = 0 + self._store = ArrayStore( field_desc={ "solution": (self.solution_dim, solution_dtype), @@ -480,6 +510,30 @@ def _maybe_resize(self, new_size: int) -> None: multiplier = 2 ** int(np.ceil(np.log2(new_size / self.capacity))) self._store.resize(multiplier * self.capacity) + def __maybe_update_threshold(self): + """Performs threshold decay. + + After :attr:`iterations_without_imp` iterations without inserting + any new novel solution into the archive, the :attr:`novelty_threshold` + is decreased to continue inserting new solutions. + """ + # If n_novel_enough == 0 it means that whether LC is True or not + # we have not inserted any novel solutions into the archive + # therefore, the number of iterations without any insertion must be updated + self._its_without_imp += 1 + if self._its_without_imp == self._max_it_without_imp: + # Restart the counter and set the new threshold to max(0.0, threshold * decay) + self._its_without_imp = 0 + new_threshold = np.max( + [ + 0.0, + self._novelty_threshold * self._threshold_decay, + ] + ) + self._novelty_threshold = np.asarray( + new_threshold, dtype=self.dtypes["measures"] + ) + def add( self, solution: ArrayLike, @@ -625,6 +679,9 @@ def add( self._store.data("measures"), **self._kdtree_kwargs ) + if self._max_it_without_imp and n_novel_enough == 0: + self.__maybe_update_threshold() + return add_info else: @@ -755,6 +812,9 @@ def add( self._store.data("measures"), **self._kdtree_kwargs ) + if self._max_it_without_imp and n_novel_enough == 0: + self.__maybe_update_threshold() + return add_info def add_single( diff --git a/tests/archives/proximity_archive_test.py b/tests/archives/proximity_archive_test.py index a98980a72..9f03cd060 100644 --- a/tests/archives/proximity_archive_test.py +++ b/tests/archives/proximity_archive_test.py @@ -865,3 +865,28 @@ def test_add_novel_and_not_novel_no_improve_bug(): objective_batch=[10.0, 10.0], measures_batch=[[0, 0], [5.0, 5.0]], ) + + +def test_add_non_novel_solution_threshold_decay(): + iterations = 5 + initial_threshold = 1.0 + archive = ProximityArchive( + solution_dim=3, + measure_dim=2, + k_neighbors=1, + novelty_threshold=initial_threshold, + initial_capacity=1, + threshold_decay=0.5, + iterations_without_imp=iterations, + ) + assert_allclose(archive.novelty_threshold, 1.0) + archive.add_single([1, 2, 3], None, [0, 0]) + + for _ in range(iterations): + # Should not be added since threshold is 1.0. + add_info = archive.add_single([1, 2, 3], None, [0.5, 0]) + assert add_info["status"] == 0 + assert_allclose(add_info["novelty"], 0.5) + + assert_archive_elites(archive, 1, measures_batch=[[0, 0]]) + assert_allclose(archive.novelty_threshold, 0.5) From b0180d705d9ea8119cf20e2f1e61536de7bb7715 Mon Sep 17 00:00:00 2001 From: Bryon Tjanaka Date: Thu, 4 Jun 2026 02:40:17 -0700 Subject: [PATCH 02/13] Rearrange names in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2405ef844..1af5b8a22 100644 --- a/README.md +++ b/README.md @@ -266,8 +266,8 @@ USC. For information on contributing to the repo, see - [Ryan Boldi](https://ryanboldi.github.io) - [Efstathios Siatras](https://siatras.com/) - [Milo Brontesi](https://github.com/zibasPk) -- [Stefanos Nikolaidis](https://stefanosnikolaidis.net) - [Alejandro Marrero](https://github.com/amarrerod) +- [Stefanos Nikolaidis](https://stefanosnikolaidis.net) We thank [Amy K. Hoover](http://amykhoover.com/) and [Julian Togelius](http://julian.togelius.com/) for their contributions deriving From 54ea13325c109b1398c83d7fa029a8c55ad45af6 Mon Sep 17 00:00:00 2001 From: Bryon Tjanaka Date: Thu, 4 Jun 2026 02:41:00 -0700 Subject: [PATCH 03/13] history --- HISTORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 2388202c3..53abb3bfb 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -15,7 +15,7 @@ - Add `DDS-CNF` density method to `DensityArchive` ({pr}`691`, {pr}`707`) - Add PyTorch to ribs[all] deps ({pr}`692`) - Add `NSLCRanker` for Novelty Search with Local Competition ({pr}`690`) -- Implements `novelty_threshold` decay in `ProximityArchive`({issue}`540`, {pr}`708`) +- Implement `novelty_threshold` decay in `ProximityArchive` ({pr}`709`) #### Documentation From 44932355fd35977af46683e4a6259230d30fa7ff Mon Sep 17 00:00:00 2001 From: Bryon Tjanaka Date: Thu, 4 Jun 2026 02:45:16 -0700 Subject: [PATCH 04/13] Edit args --- ribs/archives/_proximity_archive.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/ribs/archives/_proximity_archive.py b/ribs/archives/_proximity_archive.py index c4fee60f5..4e4287e0f 100644 --- a/ribs/archives/_proximity_archive.py +++ b/ribs/archives/_proximity_archive.py @@ -104,14 +104,12 @@ class ProximityArchive(ArchiveBase): ``objective - (-300)``. iterations_without_imp: Maximum number of iterations without inserting novel solutions, before decreasing the ``novelty_threshold`` by a factor of - ``threshold_decay``. This is an optional parameter that by - default is set to None, which means that the ``novelty_threshold`` - is not updated during the evolution. + ``threshold_decay``. This is an optional parameter that by default is set to + None, which means that the ``novelty_threshold`` is never updated. threshold_decay: Decay factor to reduce the ``novelty_treshold`` if there were ``iterations_without_imp`` iterations without inserting novelty solutions. - This is a optional float value in the range [0.0, 1.0]. Default is set to - None, which means that the ``novelty_threshold`` is not updated during - the evolution. + This is an optional float value in the range [0.0, 1.0]. The default is + None, which indicates that the ``novelty_threshold`` is never updated. seed: Value to seed the random number generator. Set to None to avoid a fixed seed. solution_dtype: Data type of the solutions. Defaults to float64 (numpy's default From 1c76f68c4d80ad6764b724dfefc53b6f3203a685 Mon Sep 17 00:00:00 2001 From: Bryon Tjanaka Date: Thu, 4 Jun 2026 02:46:53 -0700 Subject: [PATCH 05/13] Rename method --- ribs/archives/_proximity_archive.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ribs/archives/_proximity_archive.py b/ribs/archives/_proximity_archive.py index 4e4287e0f..cf46dcc64 100644 --- a/ribs/archives/_proximity_archive.py +++ b/ribs/archives/_proximity_archive.py @@ -508,7 +508,7 @@ def _maybe_resize(self, new_size: int) -> None: multiplier = 2 ** int(np.ceil(np.log2(new_size / self.capacity))) self._store.resize(multiplier * self.capacity) - def __maybe_update_threshold(self): + def _maybe_update_threshold(self): """Performs threshold decay. After :attr:`iterations_without_imp` iterations without inserting @@ -678,7 +678,7 @@ def add( ) if self._max_it_without_imp and n_novel_enough == 0: - self.__maybe_update_threshold() + self._maybe_update_threshold() return add_info @@ -811,7 +811,7 @@ def add( ) if self._max_it_without_imp and n_novel_enough == 0: - self.__maybe_update_threshold() + self._maybe_update_threshold() return add_info From 2862632eb3ec62fc93127271144a3e6dc5db3632 Mon Sep 17 00:00:00 2001 From: Bryon Tjanaka Date: Thu, 4 Jun 2026 02:47:50 -0700 Subject: [PATCH 06/13] history --- HISTORY.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 53abb3bfb..37727c5df 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,13 @@ # History +## (Forthcoming) + +### Changelog + +#### API + +- Implement `novelty_threshold` decay in `ProximityArchive` ({pr}`709`) + ## 0.11.0 ### Changelog @@ -15,7 +23,6 @@ - Add `DDS-CNF` density method to `DensityArchive` ({pr}`691`, {pr}`707`) - Add PyTorch to ribs[all] deps ({pr}`692`) - Add `NSLCRanker` for Novelty Search with Local Competition ({pr}`690`) -- Implement `novelty_threshold` decay in `ProximityArchive` ({pr}`709`) #### Documentation From cbb8dde9cb9c715bfa50b619fbbfcbc29941e4ae Mon Sep 17 00:00:00 2001 From: amarrerod Date: Thu, 4 Jun 2026 14:36:37 +0100 Subject: [PATCH 07/13] Updates PR #709 with suggested comments. - Renames iterations_without_imp to threshold_decay_itrs - Renames threshold_decay to threshold_decay_rate - Includes threshold_decay_min with default value to 0.0 - Ensures threshold_decay_rate is in the range [0.0, 1.0] - Implements two new test cases to check 1. The novelty_threshold remains constant when novel a constant flow of solutions are included. 2. That novelty_threshold remains constant when there is a mix of additions and failed insertions. --- ribs/archives/_proximity_archive.py | 85 +++++++++++++++--------- tests/archives/proximity_archive_test.py | 70 ++++++++++++++++++- 2 files changed, 120 insertions(+), 35 deletions(-) diff --git a/ribs/archives/_proximity_archive.py b/ribs/archives/_proximity_archive.py index cf46dcc64..d8b9118d4 100644 --- a/ribs/archives/_proximity_archive.py +++ b/ribs/archives/_proximity_archive.py @@ -102,14 +102,16 @@ class ProximityArchive(ArchiveBase): *subtracted* from all objectives in the archive, e.g., if your objectives go as low as -300, pass in -300 so that each objective will be transformed as ``objective - (-300)``. - iterations_without_imp: Maximum number of iterations without inserting novel + threshold_decay_itrs: Maximum number of call to ``add`` without inserting novel solutions, before decreasing the ``novelty_threshold`` by a factor of - ``threshold_decay``. This is an optional parameter that by default is set to + ``threshold_decay_rate``. This is an optional parameter that by default is set to None, which means that the ``novelty_threshold`` is never updated. - threshold_decay: Decay factor to reduce the ``novelty_treshold`` if there were - ``iterations_without_imp`` iterations without inserting novelty solutions. + threshold_decay_rate: Decay factor to reduce the ``novelty_treshold`` if there were + ``threshold_decay_itrs`` iterations without inserting novelty solutions. This is an optional float value in the range [0.0, 1.0]. The default is None, which indicates that the ``novelty_threshold`` is never updated. + threshold_decay_min: Minimum value for the ``novelty_treshold`` if threshold + decay is enabled. The default is 0.0. seed: Value to seed the random number generator. Set to None to avoid a fixed seed. solution_dtype: Data type of the solutions. Defaults to float64 (numpy's default @@ -148,8 +150,9 @@ def __init__( local_competition: bool = False, initial_capacity: Int = 128, qd_score_offset: Float = 0.0, - iterations_without_imp: Int | None = None, - threshold_decay: Float | None = None, + threshold_decay_itrs: Int | None = None, + threshold_decay_rate: Float | None = None, + threshold_decay_min: Float = 0.0, seed: Int | None = None, solution_dtype: DTypeLike = None, objective_dtype: DTypeLike = None, @@ -192,19 +195,24 @@ def __init__( # By default, threshold decay is not defined. self._max_it_without_imp = None - if iterations_without_imp is not None: - if iterations_without_imp < 0: + if threshold_decay_itrs is not None: + if threshold_decay_itrs < 0: raise ValueError( - "iterations_without_imp must be either None or a positive integer." + "threshold_decay_itrs must be either None or a positive integer." ) - if threshold_decay is None or ( - threshold_decay < 0.0 or threshold_decay > 1.0 + if threshold_decay_rate is None or ( + threshold_decay_rate <= 0.0 or threshold_decay_rate > 1.0 ): raise ValueError( - "If iterations_without_imp is not None, threshold decay must be a float in the range [0.0, 1.0]." + "If threshold_decay_itrs is not None, threshold decay must be a float in the range [0.0, 1.0]." ) - self._max_it_without_imp = int(iterations_without_imp) - self._threshold_decay = float(threshold_decay) + # The minimum could be 0.0 + if threshold_decay_min < 0.0: + raise ValueError("threshold_decay_min must be a positive float value.") + + self._max_it_without_imp = int(threshold_decay_itrs) + self._threshold_decay = float(threshold_decay_rate) + self._threshold_decay_min = float(threshold_decay_min) self._its_without_imp = 0 self._store = ArrayStore( @@ -508,29 +516,42 @@ def _maybe_resize(self, new_size: int) -> None: multiplier = 2 ** int(np.ceil(np.log2(new_size / self.capacity))) self._store.resize(multiplier * self.capacity) - def _maybe_update_threshold(self): + def _maybe_update_threshold(self, n_novel_enough: int): """Performs threshold decay. - After :attr:`iterations_without_imp` iterations without inserting + After :attr:`threshold_decay_itrs` calls to `add` without inserting any new novel solution into the archive, the :attr:`novelty_threshold` is decreased to continue inserting new solutions. + + Args: + n_novel_enough (int): Number of newly novel solution added to the archive. + If n_novel_enough is zero, we restart the number of calls to `add` + without inserting novel solutions. Otherwise, the counter is incremented + by one. When the number of calls reaches the maximum allowed, + :attr:`novelty_threshold` is decreased and the number of calls restarted. """ # If n_novel_enough == 0 it means that whether LC is True or not # we have not inserted any novel solutions into the archive # therefore, the number of iterations without any insertion must be updated - self._its_without_imp += 1 - if self._its_without_imp == self._max_it_without_imp: - # Restart the counter and set the new threshold to max(0.0, threshold * decay) + # Otherwise, we restart the counter + print(self._its_without_imp, n_novel_enough, self._novelty_threshold) + + if n_novel_enough == 0: + self._its_without_imp += 1 + if self._its_without_imp == self._max_it_without_imp: + # Restart the counter and set the new threshold to max(_threshold_decay_min, threshold * decay) + self._its_without_imp = 0 + new_threshold = np.max( + [ + self._threshold_decay_min, + self._novelty_threshold * self._threshold_decay, + ] + ) + self._novelty_threshold = np.asarray( + new_threshold, dtype=self.dtypes["measures"] + ) + else: self._its_without_imp = 0 - new_threshold = np.max( - [ - 0.0, - self._novelty_threshold * self._threshold_decay, - ] - ) - self._novelty_threshold = np.asarray( - new_threshold, dtype=self.dtypes["measures"] - ) def add( self, @@ -677,8 +698,8 @@ def add( self._store.data("measures"), **self._kdtree_kwargs ) - if self._max_it_without_imp and n_novel_enough == 0: - self._maybe_update_threshold() + if self._max_it_without_imp: + self._maybe_update_threshold(n_novel_enough) return add_info @@ -810,8 +831,8 @@ def add( self._store.data("measures"), **self._kdtree_kwargs ) - if self._max_it_without_imp and n_novel_enough == 0: - self._maybe_update_threshold() + if self._max_it_without_imp: + self._maybe_update_threshold(n_novel_enough) return add_info diff --git a/tests/archives/proximity_archive_test.py b/tests/archives/proximity_archive_test.py index 9f03cd060..61dda7e47 100644 --- a/tests/archives/proximity_archive_test.py +++ b/tests/archives/proximity_archive_test.py @@ -876,17 +876,81 @@ def test_add_non_novel_solution_threshold_decay(): k_neighbors=1, novelty_threshold=initial_threshold, initial_capacity=1, - threshold_decay=0.5, - iterations_without_imp=iterations, + threshold_decay_rate=0.5, + threshold_decay_itrs=iterations, ) assert_allclose(archive.novelty_threshold, 1.0) archive.add_single([1, 2, 3], None, [0, 0]) for _ in range(iterations): + assert_allclose(archive.novelty_threshold, 1.0) # Should not be added since threshold is 1.0. add_info = archive.add_single([1, 2, 3], None, [0.5, 0]) assert add_info["status"] == 0 assert_allclose(add_info["novelty"], 0.5) - assert_archive_elites(archive, 1, measures_batch=[[0, 0]]) + # At this point, novelty_threshold must have been updated to 0.5 assert_allclose(archive.novelty_threshold, 0.5) + assert_archive_elites(archive, 1, measures_batch=[[0, 0]]) + + +def test_constant_addition_novel_no_decay(): + # Test that the novelty_threshold keeps constant when adding novel solutions + iterations = 5 + initial_threshold = 1.0 + archive = ProximityArchive( + solution_dim=3, + measure_dim=2, + k_neighbors=1, + novelty_threshold=initial_threshold, + initial_capacity=1, + threshold_decay_itrs=iterations, + threshold_decay_rate=0.99, + ) + assert_allclose(archive.novelty_threshold, 1.0) + inner_measures = np.arange(iterations) + new_measures = np.stack([inner_measures, inner_measures], axis=1) + for i in range(iterations): + add_info = archive.add_single([1, 2, 3], None, new_measures[i]) + # The solution was added to the archive + assert add_info["status"] == 2 + # The threshold must remain the same --> 1.0 + assert_allclose(archive.novelty_threshold, 1) + + assert_archive_elites(archive, iterations, measures_batch=new_measures) + assert_allclose(archive.novelty_threshold, 1.0) + + +def test_mix_iterations_add_with_threshold_decay(): + iterations = 5 + initial_threshold = 1.0 + archive = ProximityArchive( + solution_dim=3, + measure_dim=2, + k_neighbors=1, + novelty_threshold=initial_threshold, + initial_capacity=1, + threshold_decay_itrs=iterations, + threshold_decay_rate=0.99, + ) + assert_allclose(archive.novelty_threshold, 1.0) + + # Insert and initial solution with measure [0,0] to have an reference + # thus, not_novel_measure should not be included anytime + not_novel_measure = [0.5, 0] + + for i in range(iterations * 2): + to_insert = np.asarray([i, i]) if i % 2 == 0 else not_novel_measure + add_info = archive.add_single([1, 2, 3], None, to_insert) + if i % 2 == 0: + # We should have inserted a novel solution + # The solution was added to the archive + assert add_info["status"] == 2 + # The threshold must remain the same --> 1.0 + assert_allclose(archive.novelty_threshold, 1) + else: + # This duplicated measure was not inserted + assert add_info["status"] == 0 + assert_allclose(archive.novelty_threshold, 1) + + assert_allclose(archive.novelty_threshold, 1.0) From a067e2e38e13113ed50d2a64d193035558ed1a47 Mon Sep 17 00:00:00 2001 From: amarrerod Date: Thu, 4 Jun 2026 14:47:38 +0100 Subject: [PATCH 08/13] Removes unused checker --- ribs/archives/_proximity_archive.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ribs/archives/_proximity_archive.py b/ribs/archives/_proximity_archive.py index d8b9118d4..d3f52560a 100644 --- a/ribs/archives/_proximity_archive.py +++ b/ribs/archives/_proximity_archive.py @@ -534,7 +534,6 @@ def _maybe_update_threshold(self, n_novel_enough: int): # we have not inserted any novel solutions into the archive # therefore, the number of iterations without any insertion must be updated # Otherwise, we restart the counter - print(self._its_without_imp, n_novel_enough, self._novelty_threshold) if n_novel_enough == 0: self._its_without_imp += 1 From b3eee00c6fa1721e1f53a9d4023858114eab5030 Mon Sep 17 00:00:00 2001 From: Bryon Tjanaka Date: Thu, 4 Jun 2026 21:19:18 -0700 Subject: [PATCH 09/13] Update docstrings and rearrange init Moved threshold_decay_rate first so that we can set a default for threshold_decay_itrs. This way, somebody can just set threshold_decay_rate without having to set other parameters, and threshold decay already works. --- ribs/archives/_proximity_archive.py | 88 +++++++++++++++-------------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/ribs/archives/_proximity_archive.py b/ribs/archives/_proximity_archive.py index d3f52560a..72b595d0f 100644 --- a/ribs/archives/_proximity_archive.py +++ b/ribs/archives/_proximity_archive.py @@ -102,16 +102,17 @@ class ProximityArchive(ArchiveBase): *subtracted* from all objectives in the archive, e.g., if your objectives go as low as -300, pass in -300 so that each objective will be transformed as ``objective - (-300)``. - threshold_decay_itrs: Maximum number of call to ``add`` without inserting novel - solutions, before decreasing the ``novelty_threshold`` by a factor of - ``threshold_decay_rate``. This is an optional parameter that by default is set to - None, which means that the ``novelty_threshold`` is never updated. - threshold_decay_rate: Decay factor to reduce the ``novelty_treshold`` if there were - ``threshold_decay_itrs`` iterations without inserting novelty solutions. - This is an optional float value in the range [0.0, 1.0]. The default is - None, which indicates that the ``novelty_threshold`` is never updated. - threshold_decay_min: Minimum value for the ``novelty_treshold`` if threshold - decay is enabled. The default is 0.0. + threshold_decay_rate: Decay factor to reduce the ``novelty_threshold``. When + :meth:`add` is called ``threshold_decay_itrs`` times in a row without + inserting any novel solutions, the ``novelty_threshold`` is multiplied by + this rate. The default value is None, which indicates that there is no + decay. If passed in, it must be a float value in the range [0.0, 1.0]. + threshold_decay_itrs: See ``threshold_decay_rate`` above. This parameter only + applies if ``threshold_decay_rate`` is set. The default value of 1 indicates + that the ``novelty_threshold`` will be decreased immediately after a call to + :meth:`add` has no solutions that are novel enough. + threshold_decay_min: Minimum value for the ``novelty_threshold`` if threshold + decay is enabled. seed: Value to seed the random number generator. Set to None to avoid a fixed seed. solution_dtype: Data type of the solutions. Defaults to float64 (numpy's default @@ -150,8 +151,8 @@ def __init__( local_competition: bool = False, initial_capacity: Int = 128, qd_score_offset: Float = 0.0, - threshold_decay_itrs: Int | None = None, threshold_decay_rate: Float | None = None, + threshold_decay_itrs: Int = 1, threshold_decay_min: Float = 0.0, seed: Int | None = None, solution_dtype: DTypeLike = None, @@ -192,29 +193,6 @@ def __init__( solution_dtype, objective_dtype, measures_dtype = parse_all_dtypes( dtype, solution_dtype, objective_dtype, measures_dtype, np ) - - # By default, threshold decay is not defined. - self._max_it_without_imp = None - if threshold_decay_itrs is not None: - if threshold_decay_itrs < 0: - raise ValueError( - "threshold_decay_itrs must be either None or a positive integer." - ) - if threshold_decay_rate is None or ( - threshold_decay_rate <= 0.0 or threshold_decay_rate > 1.0 - ): - raise ValueError( - "If threshold_decay_itrs is not None, threshold decay must be a float in the range [0.0, 1.0]." - ) - # The minimum could be 0.0 - if threshold_decay_min < 0.0: - raise ValueError("threshold_decay_min must be a positive float value.") - - self._max_it_without_imp = int(threshold_decay_itrs) - self._threshold_decay = float(threshold_decay_rate) - self._threshold_decay_min = float(threshold_decay_min) - self._its_without_imp = 0 - self._store = ArrayStore( field_desc={ "solution": (self.solution_dim, solution_dtype), @@ -246,6 +224,31 @@ def __init__( self._stats = None self._stats_reset() + # Set up threshold decay. + if threshold_decay_rate is None: + self._threshold_decay_rate = None + self._threshold_decay_itrs = None + self._threshold_decay_min = None + self._itrs_without_novel = None + else: + if threshold_decay_rate <= 0.0 or threshold_decay_rate > 1.0: + raise ValueError( + "If passed in, threshold_decay_rate must be a float in the range [0.0, 1.0]." + ) + if threshold_decay_itrs <= 0: + raise ValueError( + "threshold_decay_itrs must be either None or a positive integer." + ) + if threshold_decay_min < 0.0: + raise ValueError( + "threshold_decay_min must be a non-negative float value." + ) + + self._threshold_decay_rate = float(threshold_decay_rate) + self._threshold_decay_itrs = int(threshold_decay_itrs) + self._threshold_decay_min = float(threshold_decay_min) + self._itrs_without_novel = 0 + ## Properties inherited from ArchiveBase ## @property @@ -528,7 +531,8 @@ def _maybe_update_threshold(self, n_novel_enough: int): If n_novel_enough is zero, we restart the number of calls to `add` without inserting novel solutions. Otherwise, the counter is incremented by one. When the number of calls reaches the maximum allowed, - :attr:`novelty_threshold` is decreased and the number of calls restarted. + :attr:`novelty_threshold` is decreased and the number of calls + restarted. """ # If n_novel_enough == 0 it means that whether LC is True or not # we have not inserted any novel solutions into the archive @@ -536,21 +540,21 @@ def _maybe_update_threshold(self, n_novel_enough: int): # Otherwise, we restart the counter if n_novel_enough == 0: - self._its_without_imp += 1 - if self._its_without_imp == self._max_it_without_imp: + self._itrs_without_novel += 1 + if self._itrs_without_novel >= self._threshold_decay_itrs: # Restart the counter and set the new threshold to max(_threshold_decay_min, threshold * decay) - self._its_without_imp = 0 + self._itrs_without_novel = 0 new_threshold = np.max( [ self._threshold_decay_min, - self._novelty_threshold * self._threshold_decay, + self._novelty_threshold * self._threshold_decay_rate, ] ) self._novelty_threshold = np.asarray( new_threshold, dtype=self.dtypes["measures"] ) else: - self._its_without_imp = 0 + self._itrs_without_novel = 0 def add( self, @@ -697,7 +701,7 @@ def add( self._store.data("measures"), **self._kdtree_kwargs ) - if self._max_it_without_imp: + if self._threshold_decay_itrs: self._maybe_update_threshold(n_novel_enough) return add_info @@ -830,7 +834,7 @@ def add( self._store.data("measures"), **self._kdtree_kwargs ) - if self._max_it_without_imp: + if self._threshold_decay_itrs: self._maybe_update_threshold(n_novel_enough) return add_info From fbc3e063ae36b5092be1814e5ad4333cae763d64 Mon Sep 17 00:00:00 2001 From: Bryon Tjanaka Date: Fri, 5 Jun 2026 14:22:37 -0700 Subject: [PATCH 10/13] Slight refactor to maybe_update_threshold --- ribs/archives/_proximity_archive.py | 41 ++++++++++++++--------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/ribs/archives/_proximity_archive.py b/ribs/archives/_proximity_archive.py index 72b595d0f..e9dec7273 100644 --- a/ribs/archives/_proximity_archive.py +++ b/ribs/archives/_proximity_archive.py @@ -519,26 +519,29 @@ def _maybe_resize(self, new_size: int) -> None: multiplier = 2 ** int(np.ceil(np.log2(new_size / self.capacity))) self._store.resize(multiplier * self.capacity) - def _maybe_update_threshold(self, n_novel_enough: int): - """Performs threshold decay. + def _maybe_update_threshold(self, n_novel_enough: int) -> None: + """Performs threshold decay if needed. - After :attr:`threshold_decay_itrs` calls to `add` without inserting - any new novel solution into the archive, the :attr:`novelty_threshold` - is decreased to continue inserting new solutions. + After :attr:`threshold_decay_itrs` calls to `add` without inserting any new + novel solution into the archive, the :attr:`novelty_threshold` is decreased to + continue inserting new solutions. Args: - n_novel_enough (int): Number of newly novel solution added to the archive. - If n_novel_enough is zero, we restart the number of calls to `add` - without inserting novel solutions. Otherwise, the counter is incremented - by one. When the number of calls reaches the maximum allowed, - :attr:`novelty_threshold` is decreased and the number of calls + n_novel_enough (int): Number of newly novel solutions added to the archive. + If this is non-zero, we restart the number of calls to `add` without + inserting novel solutions. Otherwise, the counter is incremented by one. + When the number of calls reaches the maximum allowed, + :attr:`novelty_threshold` is decreased and the number of calls is restarted. """ - # If n_novel_enough == 0 it means that whether LC is True or not - # we have not inserted any novel solutions into the archive - # therefore, the number of iterations without any insertion must be updated - # Otherwise, we restart the counter - + # Threshold decay has not been activated. + if self._threshold_decay_rate is None: + return + + # If n_novel_enough == 0 it means that, whether local_competition is True or + # not, we have not inserted any novel solutions into the archive. Therefore, the + # number of iterations without any insertion must be updated. Otherwise, we + # restart the counter. if n_novel_enough == 0: self._itrs_without_novel += 1 if self._itrs_without_novel >= self._threshold_decay_itrs: @@ -701,9 +704,7 @@ def add( self._store.data("measures"), **self._kdtree_kwargs ) - if self._threshold_decay_itrs: - self._maybe_update_threshold(n_novel_enough) - + self._maybe_update_threshold(n_novel_enough) return add_info else: @@ -834,9 +835,7 @@ def add( self._store.data("measures"), **self._kdtree_kwargs ) - if self._threshold_decay_itrs: - self._maybe_update_threshold(n_novel_enough) - + self._maybe_update_threshold(n_novel_enough) return add_info def add_single( From 1c32c1ae2d25301a0f4018e4a1d599c679bf7419 Mon Sep 17 00:00:00 2001 From: Bryon Tjanaka Date: Fri, 5 Jun 2026 16:56:45 -0700 Subject: [PATCH 11/13] Rearrange comments for _maybe_update_threshold --- ribs/archives/_proximity_archive.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/ribs/archives/_proximity_archive.py b/ribs/archives/_proximity_archive.py index e9dec7273..a59372e73 100644 --- a/ribs/archives/_proximity_archive.py +++ b/ribs/archives/_proximity_archive.py @@ -522,31 +522,23 @@ def _maybe_resize(self, new_size: int) -> None: def _maybe_update_threshold(self, n_novel_enough: int) -> None: """Performs threshold decay if needed. - After :attr:`threshold_decay_itrs` calls to `add` without inserting any new - novel solution into the archive, the :attr:`novelty_threshold` is decreased to - continue inserting new solutions. - Args: n_novel_enough (int): Number of newly novel solutions added to the archive. - If this is non-zero, we restart the number of calls to `add` without - inserting novel solutions. Otherwise, the counter is incremented by one. - When the number of calls reaches the maximum allowed, - :attr:`novelty_threshold` is decreased and the number of calls is - restarted. """ - # Threshold decay has not been activated. + # Threshold decay has not been activated, so do nothing. if self._threshold_decay_rate is None: return - # If n_novel_enough == 0 it means that, whether local_competition is True or - # not, we have not inserted any novel solutions into the archive. Therefore, the - # number of iterations without any insertion must be updated. Otherwise, we - # restart the counter. if n_novel_enough == 0: + # If n_novel_enough == 0, it means that, whether local_competition is True + # or not, we have not inserted any novel solutions into the archive. Thus, + # the number of iterations without novel solutions is updated. self._itrs_without_novel += 1 + if self._itrs_without_novel >= self._threshold_decay_itrs: - # Restart the counter and set the new threshold to max(_threshold_decay_min, threshold * decay) - self._itrs_without_novel = 0 + # If there have been at least `threshold_decay_itrs` calls to `add` + # without inserting any novel solutions, then we update the threshold to + # max(threshold_decay_min, threshold * decay). new_threshold = np.max( [ self._threshold_decay_min, @@ -556,7 +548,11 @@ def _maybe_update_threshold(self, n_novel_enough: int) -> None: self._novelty_threshold = np.asarray( new_threshold, dtype=self.dtypes["measures"] ) + + # Restart the counter since the threshold was just updated. + self._itrs_without_novel = 0 else: + # If n_novel_enough is not 0, then we restart the counter. self._itrs_without_novel = 0 def add( From 70400856292a4421e0a04a82f0e1e9dc2487717b Mon Sep 17 00:00:00 2001 From: Bryon Tjanaka Date: Fri, 5 Jun 2026 17:21:39 -0700 Subject: [PATCH 12/13] Add extra comment --- tests/archives/proximity_archive_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/archives/proximity_archive_test.py b/tests/archives/proximity_archive_test.py index 61dda7e47..5b1140d51 100644 --- a/tests/archives/proximity_archive_test.py +++ b/tests/archives/proximity_archive_test.py @@ -834,6 +834,7 @@ def test_lc_add_batch_replace_same_cell(): def test_add_novel_and_not_novel_no_improve_bug(): + """See https://github.com/icaros-usc/pyribs/pull/704/.""" archive = ProximityArchive( solution_dim=3, measure_dim=2, From a866c663efb243801d3430a4f3da4abe31438255 Mon Sep 17 00:00:00 2001 From: Bryon Tjanaka Date: Fri, 5 Jun 2026 17:47:28 -0700 Subject: [PATCH 13/13] Edit tests --- tests/archives/proximity_archive_test.py | 86 +++++++++++++++++++----- 1 file changed, 69 insertions(+), 17 deletions(-) diff --git a/tests/archives/proximity_archive_test.py b/tests/archives/proximity_archive_test.py index 5b1140d51..13c17bd5e 100644 --- a/tests/archives/proximity_archive_test.py +++ b/tests/archives/proximity_archive_test.py @@ -869,6 +869,7 @@ def test_add_novel_and_not_novel_no_improve_bug(): def test_add_non_novel_solution_threshold_decay(): + """novelty_threshold decays when no novel solutions are added.""" iterations = 5 initial_threshold = 1.0 archive = ProximityArchive( @@ -890,13 +891,17 @@ def test_add_non_novel_solution_threshold_decay(): assert add_info["status"] == 0 assert_allclose(add_info["novelty"], 0.5) - # At this point, novelty_threshold must have been updated to 0.5 + # At this point, novelty_threshold must have been updated to 0.5. assert_allclose(archive.novelty_threshold, 0.5) assert_archive_elites(archive, 1, measures_batch=[[0, 0]]) + # Now when we add the solution, it is accepted. + add_info = archive.add_single([1, 2, 3], None, [0.5, 0]) + assert_archive_elites(archive, 2, measures_batch=[[0, 0], [0.5, 0]]) + -def test_constant_addition_novel_no_decay(): - # Test that the novelty_threshold keeps constant when adding novel solutions +def test_no_threshold_decay_when_adding_novel_solutions(): + """novelty_threshold remains constant when adding novel solutions.""" iterations = 5 initial_threshold = 1.0 archive = ProximityArchive( @@ -906,23 +911,26 @@ def test_constant_addition_novel_no_decay(): novelty_threshold=initial_threshold, initial_capacity=1, threshold_decay_itrs=iterations, - threshold_decay_rate=0.99, + threshold_decay_rate=0.5, ) assert_allclose(archive.novelty_threshold, 1.0) inner_measures = np.arange(iterations) new_measures = np.stack([inner_measures, inner_measures], axis=1) - for i in range(iterations): - add_info = archive.add_single([1, 2, 3], None, new_measures[i]) - # The solution was added to the archive + + for m in new_measures: + add_info = archive.add_single([1, 2, 3], None, m) + # The solution was added to the archive. assert add_info["status"] == 2 # The threshold must remain the same --> 1.0 - assert_allclose(archive.novelty_threshold, 1) + assert_allclose(archive.novelty_threshold, 1.0) assert_archive_elites(archive, iterations, measures_batch=new_measures) assert_allclose(archive.novelty_threshold, 1.0) -def test_mix_iterations_add_with_threshold_decay(): +def test_mix_novel_and_not_novel_for_threshold_decay(): + """novelty_threshold responds correctly when we mix novel and not novel + solutions.""" iterations = 5 initial_threshold = 1.0 archive = ProximityArchive( @@ -932,26 +940,70 @@ def test_mix_iterations_add_with_threshold_decay(): novelty_threshold=initial_threshold, initial_capacity=1, threshold_decay_itrs=iterations, - threshold_decay_rate=0.99, + threshold_decay_rate=0.5, ) assert_allclose(archive.novelty_threshold, 1.0) - # Insert and initial solution with measure [0,0] to have an reference - # thus, not_novel_measure should not be included anytime + # Insert an initial solution with measure [0,0] to have a reference. Thus, + # not_novel_measure should never be included. not_novel_measure = [0.5, 0] for i in range(iterations * 2): to_insert = np.asarray([i, i]) if i % 2 == 0 else not_novel_measure add_info = archive.add_single([1, 2, 3], None, to_insert) if i % 2 == 0: - # We should have inserted a novel solution - # The solution was added to the archive + # We should have inserted a novel solution. assert add_info["status"] == 2 # The threshold must remain the same --> 1.0 - assert_allclose(archive.novelty_threshold, 1) + assert_allclose(archive.novelty_threshold, 1.0) else: - # This duplicated measure was not inserted + # This duplicated measure should not be inserted. assert add_info["status"] == 0 - assert_allclose(archive.novelty_threshold, 1) + assert_allclose(archive.novelty_threshold, 1.0) assert_allclose(archive.novelty_threshold, 1.0) + + +def test_add_with_threshold_decay(): + """Since the threshold_decay tests above use add_single, this uses add() for + completeness.""" + iterations = 1 + initial_threshold = 1.0 + archive = ProximityArchive( + solution_dim=3, + measure_dim=2, + k_neighbors=1, + novelty_threshold=initial_threshold, + initial_capacity=1, + threshold_decay_itrs=iterations, + threshold_decay_rate=0.5, + ) + assert_allclose(archive.novelty_threshold, 1.0) + + add_info = archive.add([[1, 2, 3]], [0.0], [[0.0, 0.0]]) + assert_equal(add_info["status"], [2]) + assert_allclose(archive.novelty_threshold, 1.0) + + # One solution is novel enough while the other is not. + add_info = archive.add([[4, 5, 6], [7, 8, 9]], [0.0, 0.0], [[0.5, 0.0], [1.0, 0.0]]) + assert_equal(add_info["status"], [0, 2]) + assert_allclose(archive.novelty_threshold, 1.0) + + # Neither solution is novel enough, so the threshold decreases immediately. + add_info = archive.add( + [[10, 11, 12], [13, 14, 15]], [0.0, 0.0], [[1.5, 0.0], [1.75, 0.0]] + ) + assert_equal(add_info["status"], [0, 0]) + assert_allclose(archive.novelty_threshold, 0.5) + + # Now both solutions can be added. + add_info = archive.add( + [[10, 11, 12], [13, 14, 15]], [0.0, 0.0], [[1.5, 0.0], [1.75, 0.0]] + ) + assert_equal(add_info["status"], [2, 2]) + assert_allclose(archive.novelty_threshold, 0.5) + + # Check the elites in the archive. + assert_archive_elites( + archive, 4, measures_batch=[[0.0, 0.0], [1.0, 0.0], [1.5, 0.0], [1.75, 0.0]] + )