Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/tutorials/evaluator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
87 changes: 87 additions & 0 deletions src/jmetal/util/evaluator.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand All @@ -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:
Expand Down
126 changes: 126 additions & 0 deletions tests/util/test_evaluator_with_cache.py
Original file line number Diff line number Diff line change
@@ -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 == []
Loading