diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f0689e0..d3ce71d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Fixed + +- Knapsack and ZDT5 now initialize solutions with their sampled random bits + instead of leaving all bits false. Knapsack also supports an empty bit vector. + * [Current] * Added the multi-objective TSP problem * Added the Average Hausdorff Distance (AHD) quality indicator. diff --git a/src/jmetal/problem/multiobjective/zdt.py b/src/jmetal/problem/multiobjective/zdt.py index 47bc396a..f68965eb 100644 --- a/src/jmetal/problem/multiobjective/zdt.py +++ b/src/jmetal/problem/multiobjective/zdt.py @@ -223,8 +223,13 @@ def eval_v(self, value: int) -> float: return 1.0 def create_solution(self, rng: np.random.Generator | None = None) -> BinarySolution: - """ - Create a new random solution. + """Create a solution containing the sampled random bits. + + Args: + rng: Random generator. Uses Python's global random generator when omitted. + + Returns: + A binary solution whose bits are true for scalar draws below 0.5. """ solution = BinarySolution( number_of_variables=self.total_number_of_bits, @@ -234,11 +239,9 @@ def create_solution(self, rng: np.random.Generator | None = None) -> BinarySolut # Initialize with random bits if rng is not None: - for i in range(self.total_number_of_bits): - solution.variables[i] = rng.random() < 0.5 + solution.variables = [rng.random() < 0.5 for _ in range(self.total_number_of_bits)] else: - for i in range(self.total_number_of_bits): - solution.variables[i] = random.random() < 0.5 + solution.variables = [random.random() < 0.5 for _ in range(self.total_number_of_bits)] return solution diff --git a/src/jmetal/problem/singleobjective/knapsack.py b/src/jmetal/problem/singleobjective/knapsack.py index 7ebbf381..0b56f932 100644 --- a/src/jmetal/problem/singleobjective/knapsack.py +++ b/src/jmetal/problem/singleobjective/knapsack.py @@ -91,17 +91,24 @@ def evaluate(self, solution: BinarySolution) -> BinarySolution: return solution def create_solution(self, rng: np.random.Generator | None = None) -> BinarySolution: + """Create a solution containing the sampled random bits. + + Args: + rng: Random generator. Uses NumPy's global generator when omitted. + + Returns: + A binary solution with one sampled bit per item, or no bits for zero items. + """ new_solution = BinarySolution( number_of_variables=self.number_of_bits, number_of_objectives=self.number_of_objectives(), ) - # The BinarySolution initializes with empty variables, we need to set the bits - # The bits will be stored in variables[0] as a numpy array + # Initialize the flat bit vector through its storage setter. if rng is not None: - new_solution.variables[0] = rng.choice([True, False], size=self.number_of_bits) + new_solution.bits = rng.choice([True, False], size=self.number_of_bits) else: - new_solution.variables[0] = np.random.choice([True, False], size=self.number_of_bits) + new_solution.bits = np.random.choice([True, False], size=self.number_of_bits) return new_solution def name(self): diff --git a/tests/problem/multiobjective/test_zdt.py b/tests/problem/multiobjective/test_zdt.py index 988e3599..4038818a 100644 --- a/tests/problem/multiobjective/test_zdt.py +++ b/tests/problem/multiobjective/test_zdt.py @@ -1,4 +1,8 @@ import unittest +from unittest import mock + +import numpy as np +import pytest from jmetal.problem.multiobjective.zdt import ZDT1, ZDT2, ZDT3, ZDT4, ZDT5, ZDT6 @@ -247,5 +251,26 @@ def test_should_get_name_return_the_right_name(self): self.assertEqual("ZDT5", problem.name()) +class TestZDT5Initialization: + """Regression tests for storing scalar samples as binary values.""" + + @pytest.mark.parametrize("use_generator", [True, False]) + def test_should_store_bits_using_the_existing_threshold(self, use_generator: bool) -> None: + """A draw equal to 0.5 remains false for both random sources.""" + draws: list[float] = [0.0, 0.49, 0.5, 0.99] * 20 + expected: list[bool] = [True, True, False, False] * 20 + rng = mock.Mock(spec=np.random.Generator) + rng.random.side_effect = draws + with mock.patch( + "jmetal.problem.multiobjective.zdt.random.random", side_effect=draws + ) as global_random: + solution = ZDT5().create_solution(rng if use_generator else None) + + assert solution.variables == expected + selected = rng.random if use_generator else global_random + assert selected.call_args_list == [mock.call()] * len(draws) + (global_random if use_generator else rng.random).assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/tests/problem/singleobjective/test_knapsack.py b/tests/problem/singleobjective/test_knapsack.py index 42ba5824..fb6e028a 100644 --- a/tests/problem/singleobjective/test_knapsack.py +++ b/tests/problem/singleobjective/test_knapsack.py @@ -1,6 +1,9 @@ import unittest from unittest import mock +import numpy as np +import pytest + from jmetal.problem.singleobjective.knapsack import Knapsack @@ -58,3 +61,32 @@ def test_should_create_solution_from_file(self) -> None: def test_should_get_name_return_the_right_name(self): problem = Knapsack() self.assertEqual("Knapsack", problem.name()) + + +class TestKnapsackInitialization: + """Regression tests for storing sampled bits during initialization.""" + + @pytest.mark.parametrize("use_generator", [True, False]) + def test_should_store_sampled_bits(self, use_generator: bool) -> None: + """Both random sources must populate the returned solution's storage.""" + expected: np.ndarray = np.array([True, False, False, True, True]) + rng = mock.Mock(spec=np.random.Generator) + rng.choice.return_value = expected + with mock.patch( + "jmetal.problem.singleobjective.knapsack.np.random.choice", return_value=expected + ) as global_choice: + solution = Knapsack(5).create_solution(rng if use_generator else None) + + np.testing.assert_array_equal(solution.bits, expected) + selected = rng.choice if use_generator else global_choice + selected.assert_called_once_with([True, False], size=5) + (global_choice if use_generator else rng.choice).assert_not_called() + + @pytest.mark.parametrize("use_generator", [True, False]) + def test_should_initialize_zero_items(self, use_generator: bool) -> None: + """Zero items produce an empty vector for either random source.""" + rng = np.random.default_rng(42) if use_generator else None + + solution = Knapsack(0).create_solution(rng) + + assert solution.bits.size == 0