From e8cb0bb2b58e5887822305ab7f460a779055af58 Mon Sep 17 00:00:00 2001 From: Zijun Li <65371650+ZijunLee@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:14:48 +0200 Subject: [PATCH 1/3] feat(evaluator): add cached evaluator wrapper --- src/jmetal/util/evaluator.py | 87 ++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/jmetal/util/evaluator.py b/src/jmetal/util/evaluator.py index fd751134..7f47fe2a 100644 --- a/src/jmetal/util/evaluator.py +++ b/src/jmetal/util/evaluator.py @@ -1,6 +1,7 @@ import copy import functools from abc import ABC, abstractmethod +from collections.abc import Hashable from concurrent.futures import ThreadPoolExecutor from multiprocessing.pool import Pool, ThreadPool from typing import Generic, TypeVar @@ -16,6 +17,7 @@ pass from jmetal.core.problem import Problem +from jmetal.core.solution import Solution from jmetal.util.archive import Archive S = TypeVar("S") @@ -31,6 +33,91 @@ def evaluate_solution(solution: S, problem: Problem) -> None: problem.evaluate(solution) +class EvaluatorWithCache(Evaluator[S]): + """Cache evaluations while delegating cache misses to another evaluator. + + The cache key is built from a solution's type and decision variables. Objective and + constraint values are copied into the cache, so later mutations of an evaluated solution do + not alter cached results. A cache is scoped to one problem instance and is cleared when the + evaluator is used with a different problem. + + Args: + base_evaluator: Evaluator used for solutions that are not already cached. + """ + + def __init__(self, base_evaluator: Evaluator[S]) -> None: + self.base_evaluator = base_evaluator + self.cache: dict[Hashable, tuple[tuple[float, ...], tuple[float, ...]]] = {} + self._problem: Problem | None = None + + def evaluate(self, solution_list: list[S], problem: Problem) -> list[S]: + if problem is not self._problem: + self.cache.clear() + self._problem = problem + + pending_solutions: dict[Hashable, list[S]] = {} + for solution in solution_list: + key = self._solution_key(solution) + cached_values = self.cache.get(key) + if cached_values is None: + pending_solutions.setdefault(key, []).append(solution) + else: + self._restore_values(solution, cached_values) + + representatives = [solutions[0] for solutions in pending_solutions.values()] + if not representatives: + return solution_list + + evaluated_solutions = self.base_evaluator.evaluate(representatives, problem) + if len(evaluated_solutions) != len(representatives): + raise ValueError("The base evaluator must return one solution per input solution") + + for (key, equivalent_solutions), evaluated_solution in zip( + pending_solutions.items(), evaluated_solutions, strict=True + ): + cached_values = ( + tuple(evaluated_solution.objectives), + tuple(evaluated_solution.constraints), + ) + self.cache[key] = cached_values + for solution in equivalent_solutions: + self._restore_values(solution, cached_values) + + return solution_list + + @classmethod + def _solution_key(cls, solution: S) -> Hashable: + return (type(solution), *(cls._to_hashable(value) for value in solution.variables)) + + @classmethod + def _to_hashable(cls, value: object) -> Hashable: + if isinstance(value, Solution): + return ( + type(value), + *(cls._to_hashable(variable) for variable in value.variables), + ) + if isinstance(value, dict): + return frozenset( + (cls._to_hashable(key), cls._to_hashable(item)) for key, item in value.items() + ) + if isinstance(value, (list, tuple)): + return tuple(cls._to_hashable(item) for item in value) + if isinstance(value, set): + return frozenset(cls._to_hashable(item) for item in value) + if isinstance(value, Hashable): + return value + + raise TypeError(f"Solution variable of type {type(value).__name__} is not hashable") + + @staticmethod + def _restore_values( + solution: S, cached_values: tuple[tuple[float, ...], tuple[float, ...]] + ) -> None: + objectives, constraints = cached_values + solution.objectives = list(objectives) + solution.constraints = list(constraints) + + class SequentialEvaluator(Evaluator[S]): def evaluate(self, solution_list: list[S], problem: Problem) -> list[S]: for solution in solution_list: From f2787bd75b9b7955c34221dbd307a8e90bd62094 Mon Sep 17 00:00:00 2001 From: Zijun Li <65371650+ZijunLee@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:16:54 +0200 Subject: [PATCH 2/3] test(evaluator): cover cached evaluator behavior --- tests/util/test_evaluator_with_cache.py | 126 ++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/util/test_evaluator_with_cache.py diff --git a/tests/util/test_evaluator_with_cache.py b/tests/util/test_evaluator_with_cache.py new file mode 100644 index 00000000..d118fd50 --- /dev/null +++ b/tests/util/test_evaluator_with_cache.py @@ -0,0 +1,126 @@ +import copy + +from jmetal.core.problem import FloatProblem +from jmetal.core.solution import FloatSolution +from jmetal.util.evaluator import Evaluator, EvaluatorWithCache + + +class CountingProblem(FloatProblem): + def __init__(self) -> None: + super().__init__() + self.lower_bound = [0.0, 0.0] + self.upper_bound = [10.0, 10.0] + + def number_of_objectives(self) -> int: + return 1 + + def number_of_constraints(self) -> int: + return 1 + + def evaluate(self, solution: FloatSolution) -> FloatSolution: + solution.objectives[0] = sum(solution.variables) + solution.constraints[0] = solution.variables[0] - solution.variables[1] + return solution + + def name(self) -> str: + return "Counting problem" + + +class CountingEvaluator(Evaluator[FloatSolution]): + def __init__(self, return_copies: bool = False) -> None: + self.evaluated_variables: list[list[list[float]]] = [] + self.return_copies = return_copies + + def evaluate( + self, solution_list: list[FloatSolution], problem: FloatProblem + ) -> list[FloatSolution]: + self.evaluated_variables.append([solution.variables for solution in solution_list]) + evaluated_solutions = [] + for solution in solution_list: + evaluated_solution = copy.copy(solution) if self.return_copies else solution + evaluated_solutions.append(problem.evaluate(evaluated_solution)) + + return evaluated_solutions + + +def solution(problem: CountingProblem, variables: list[float]) -> FloatSolution: + result = problem.create_solution() + result.variables = variables + return result + + +class TestEvaluatorWithCache: + def test_should_evaluate_each_distinct_solution_once_and_preserve_order(self) -> None: + problem = CountingProblem() + base_evaluator = CountingEvaluator() + evaluator = EvaluatorWithCache(base_evaluator) + solutions = [ + solution(problem, [1.0, 2.0]), + solution(problem, [3.0, 1.0]), + solution(problem, [1.0, 2.0]), + ] + + result = evaluator.evaluate(solutions, problem) + + assert result is solutions + assert base_evaluator.evaluated_variables == [[[1.0, 2.0], [3.0, 1.0]]] + assert [item.variables for item in result] == [ + [1.0, 2.0], + [3.0, 1.0], + [1.0, 2.0], + ] + assert [item.objectives for item in result] == [[3.0], [4.0], [3.0]] + assert [item.constraints for item in result] == [[-1.0], [2.0], [-1.0]] + + def test_should_reuse_objectives_and_constraints_across_calls(self) -> None: + problem = CountingProblem() + base_evaluator = CountingEvaluator() + evaluator = EvaluatorWithCache(base_evaluator) + first = solution(problem, [2.0, 5.0]) + evaluator.evaluate([first], problem) + first.objectives[0] = 99.0 + first.constraints[0] = 99.0 + cached = solution(problem, [2.0, 5.0]) + + evaluator.evaluate([cached], problem) + + assert len(base_evaluator.evaluated_variables) == 1 + assert cached.objectives == [7.0] + assert cached.constraints == [-3.0] + + def test_should_copy_results_returned_by_parallel_style_evaluator(self) -> None: + problem = CountingProblem() + base_evaluator = CountingEvaluator(return_copies=True) + evaluator = EvaluatorWithCache(base_evaluator) + unevaluated = solution(problem, [4.0, 1.0]) + + result = evaluator.evaluate([unevaluated], problem) + + assert result == [unevaluated] + assert result[0] is unevaluated + assert unevaluated.objectives == [5.0] + assert unevaluated.constraints == [3.0] + + def test_should_clear_cache_when_problem_instance_changes(self) -> None: + first_problem = CountingProblem() + second_problem = CountingProblem() + base_evaluator = CountingEvaluator() + evaluator = EvaluatorWithCache(base_evaluator) + + evaluator.evaluate([solution(first_problem, [1.0, 1.0])], first_problem) + evaluator.evaluate([solution(second_problem, [1.0, 1.0])], second_problem) + + assert base_evaluator.evaluated_variables == [ + [[1.0, 1.0]], + [[1.0, 1.0]], + ] + + def test_should_return_empty_list_without_calling_base_evaluator(self) -> None: + problem = CountingProblem() + base_evaluator = CountingEvaluator() + evaluator = EvaluatorWithCache(base_evaluator) + + result = evaluator.evaluate([], problem) + + assert result == [] + assert base_evaluator.evaluated_variables == [] From 95a46ddbea483b7df6ec52dd1a81ede52dab915f Mon Sep 17 00:00:00 2001 From: Zijun Li <65371650+ZijunLee@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:22:15 +0200 Subject: [PATCH 3/3] docs(evaluator): document cached evaluations --- docs/tutorials/evaluator.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/tutorials/evaluator.md b/docs/tutorials/evaluator.md index d20a1d35..9b4cc16d 100644 --- a/docs/tutorials/evaluator.md +++ b/docs/tutorials/evaluator.md @@ -26,6 +26,34 @@ algorithm = NSGAII( ::: jmetal.util.evaluator.SequentialEvaluator +## Cached evaluations + +`EvaluatorWithCache` wraps any evaluator and avoids evaluating the same decision variables more +than once. It is useful when each evaluation calls an expensive simulator. Cache misses are passed +to the wrapped evaluator, so the same wrapper works with sequential and parallel evaluators: + +```python +from jmetal.util.evaluator import EvaluatorWithCache, MultiprocessEvaluator + +evaluator = EvaluatorWithCache(MultiprocessEvaluator(processes=8)) + +algorithm = NSGAII( + problem=problem, + population_size=100, + offspring_population_size=100, + ... + population_evaluator=evaluator, +) +``` + +The wrapper restores both objective and constraint values for cached solutions. The algorithm's +evaluation counter still advances by the population size, including cache hits, so use a time-based +termination criterion when comparing the simulator work saved by caching. + +### API + +::: jmetal.util.evaluator.EvaluatorWithCache + ## Parallel Solutions can also be evaluated in parallel, using threads or processes: