diff --git a/alns/ALNS.py b/alns/ALNS.py index 66641090..3faef7c0 100644 --- a/alns/ALNS.py +++ b/alns/ALNS.py @@ -8,6 +8,7 @@ from alns.Statistics import Statistics from alns.criteria import AcceptanceCriterion from alns.weight_schemes import WeightScheme +from alns.stopping_criteria import StoppingCriterion # Potential candidate solution consideration outcomes. _BEST = 0 @@ -21,7 +22,6 @@ class ALNS: - def __init__(self, rnd_state: rnd.RandomState = rnd.RandomState()): """ Implements the adaptive large neighbourhood search (ALNS) algorithm. @@ -107,12 +107,14 @@ def add_repair_operator(self, op: _OperatorType, name: str = None): """ self._repair_operators[name if name else op.__name__] = op - def iterate(self, - initial_solution: State, - weight_scheme: WeightScheme, - crit: AcceptanceCriterion, - iterations: int = 10_000, - **kwargs) -> Result: + def iterate( + self, + initial_solution: State, + weight_scheme: WeightScheme, + crit: AcceptanceCriterion, + stop: StoppingCriterion, + **kwargs, + ) -> Result: """ Runs the adaptive large neighbourhood search heuristic [1], using the previously set destroy and repair operators. The first solution is set @@ -129,8 +131,10 @@ def iterate(self, crit The acceptance criterion to use for candidate states. See also the ``alns.criteria`` module for an overview. - iterations - The number of iterations. Default 10_000. + stop + The stopping criterion to use for stopping the iterations. + See also the ``alns.stopping_criteria`` module for an overview. + **kwargs Optional keyword arguments. These are passed to the operators, including callbacks. @@ -156,10 +160,9 @@ class of vehicle routing problems with backhauls. *European Journal of Operational Research*, 171: 750–775, 2006. """ if len(self.destroy_operators) == 0 or len(self.repair_operators) == 0: - raise ValueError("Missing at least one destroy or repair operator.") - - if iterations < 0: - raise ValueError("Negative number of iterations.") + raise ValueError( + "Missing at least one destroy or repair operator." + ) curr = best = initial_solution @@ -167,7 +170,7 @@ class of vehicle routing problems with backhauls. *European Journal of stats.collect_objective(initial_solution.objective()) stats.collect_runtime(time.perf_counter()) - for iteration in range(iterations): + while not stop(self._rnd_state, best, curr): d_idx, r_idx = weight_scheme.select_operators(self._rnd_state) d_name, d_operator = self.destroy_operators[d_idx] @@ -176,11 +179,9 @@ class of vehicle routing problems with backhauls. *European Journal of destroyed = d_operator(curr, self._rnd_state, **kwargs) cand = r_operator(destroyed, self._rnd_state, **kwargs) - best, curr, s_idx = self._eval_cand(crit, - best, - curr, - cand, - **kwargs) + best, curr, s_idx = self._eval_cand( + crit, best, curr, cand, **kwargs + ) weight_scheme.update_weights(d_idx, r_idx, s_idx) @@ -206,12 +207,12 @@ def on_best(self, func: _OperatorType): self._on_best = func def _eval_cand( - self, - crit: AcceptanceCriterion, - best: State, - curr: State, - cand: State, - **kwargs + self, + crit: AcceptanceCriterion, + best: State, + curr: State, + cand: State, + **kwargs, ) -> Tuple[State, State, int]: """ Considers the candidate solution by comparing it against the best and diff --git a/alns/Statistics.py b/alns/Statistics.py index adeb7895..a5b801c6 100644 --- a/alns/Statistics.py +++ b/alns/Statistics.py @@ -24,6 +24,20 @@ def objectives(self) -> np.ndarray: """ return np.array(self._objectives) + @property + def start_time(self) -> float: + """ + Return the reference start time to compute the runtimes. + """ + return self._runtimes[0] + + @property + def total_runtime(self) -> float: + """ + Return the total runtime (in seconds). + """ + return self._runtimes[-1] - self._runtimes[0] + @property def runtimes(self) -> np.ndarray: """ diff --git a/alns/stopping_criteria/MaxIterations.py b/alns/stopping_criteria/MaxIterations.py new file mode 100644 index 00000000..6e46b5ba --- /dev/null +++ b/alns/stopping_criteria/MaxIterations.py @@ -0,0 +1,25 @@ +from numpy.random import RandomState + +from alns.State import State +from alns.stopping_criteria.StoppingCriterion import StoppingCriterion + + +class MaxIterations(StoppingCriterion): + def __init__(self, max_iterations: int): + """ + Criterion that stops after a maximum number of iterations. + """ + if max_iterations < 0: + raise ValueError("Max iterations must be non-negative.") + + self._max_iterations = max_iterations + self._current_iteration = 0 + + @property + def max_iterations(self) -> int: + return self._max_iterations + + def __call__(self, rnd: RandomState, best: State, current: State) -> bool: + self._current_iteration += 1 + + return self._current_iteration > self.max_iterations diff --git a/alns/stopping_criteria/MaxRuntime.py b/alns/stopping_criteria/MaxRuntime.py new file mode 100644 index 00000000..fdde6473 --- /dev/null +++ b/alns/stopping_criteria/MaxRuntime.py @@ -0,0 +1,29 @@ +import time + +from typing import Optional +from numpy.random import RandomState + +from alns.State import State +from alns.stopping_criteria.StoppingCriterion import StoppingCriterion + + +class MaxRuntime(StoppingCriterion): + def __init__(self, max_runtime: float): + """ + Criterion that stops after a specified maximum runtime. + """ + if max_runtime < 0: + raise ValueError("Max runtime must be non-negative.") + + self._max_runtime = max_runtime + self._start_runtime: Optional[float] = None + + @property + def max_runtime(self) -> float: + return self._max_runtime + + def __call__(self, rnd: RandomState, best: State, current: State) -> bool: + if self._start_runtime is None: + self._start_runtime = time.perf_counter() + + return time.perf_counter () - self._start_runtime > self.max_runtime diff --git a/alns/stopping_criteria/StoppingCriterion.py b/alns/stopping_criteria/StoppingCriterion.py new file mode 100644 index 00000000..3bf6960b --- /dev/null +++ b/alns/stopping_criteria/StoppingCriterion.py @@ -0,0 +1,31 @@ +from abc import ABC, abstractmethod + +from numpy.random import RandomState + +from alns.State import State + + +class StoppingCriterion(ABC): + """ + Base class from which to implement a stopping criterion. + """ + + @abstractmethod + def __call__(self, rnd: RandomState, best: State, current: State) -> bool: + """ + Determines whether to stop based on the implemented stopping criterion. + + Parameters + ---------- + rnd + May be used to draw random numbers from. + best + The best solution state observed so far. + current + The current solution state. + + Returns + ------- + Whether to stop the iteration (True), or not (False). + """ + return NotImplemented diff --git a/alns/stopping_criteria/__init__.py b/alns/stopping_criteria/__init__.py new file mode 100644 index 00000000..7524355d --- /dev/null +++ b/alns/stopping_criteria/__init__.py @@ -0,0 +1,3 @@ +from .MaxIterations import MaxIterations +from .MaxRuntime import MaxRuntime +from .StoppingCriterion import StoppingCriterion diff --git a/alns/stopping_criteria/tests/__init__.py b/alns/stopping_criteria/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/alns/stopping_criteria/tests/test_max_iterations.py b/alns/stopping_criteria/tests/test_max_iterations.py new file mode 100644 index 00000000..f8eae995 --- /dev/null +++ b/alns/stopping_criteria/tests/test_max_iterations.py @@ -0,0 +1,52 @@ +import pytest + +from numpy.random import RandomState +from numpy.testing import assert_, assert_raises + +from alns.stopping_criteria import MaxIterations +from alns.tests.states import Zero + + +@pytest.mark.parametrize("max_iterations", [-1, -42, -10000]) +def test_raise_negative_parameters(max_iterations: int): + """ + Maximum iterations cannot be negative. + """ + with assert_raises(ValueError): + MaxIterations(max_iterations) + + +@pytest.mark.parametrize("max_iterations", [1, 42, 10000]) +def test_does_not_raise(max_iterations: int): + """ + Valid parameters should not raise. + """ + MaxIterations(max_iterations) + + +@pytest.mark.parametrize("max_iterations", [1, 42, 10000]) +def test_max_iterations(max_iterations): + """ + Test if the max iterations parameter is correctly set. + """ + stop = MaxIterations(max_iterations) + assert stop.max_iterations == max_iterations + + +def test_before_max_iterations(): + stop = MaxIterations(100) + rnd = RandomState(0) + + for _ in range(100): + assert_(not stop(rnd, Zero(), Zero())) + + +def test_after_max_iterations(): + stop = MaxIterations(100) + rnd = RandomState() + + for _ in range(100): + stop(rnd, Zero(), Zero()) + + for _ in range(100): + assert_(stop(rnd, Zero(), Zero())) diff --git a/alns/stopping_criteria/tests/test_max_runtime.py b/alns/stopping_criteria/tests/test_max_runtime.py new file mode 100644 index 00000000..e3e9a2ef --- /dev/null +++ b/alns/stopping_criteria/tests/test_max_runtime.py @@ -0,0 +1,65 @@ +import time +import pytest + +from numpy.random import RandomState +from numpy.testing import assert_, assert_almost_equal, assert_raises + +from alns.stopping_criteria import MaxRuntime +from alns.tests.states import Zero + + +def sleep(duration, get_now=time.perf_counter): + """ + Custom sleep function. Built-in time.sleep function is not precise + and has different performances depending on the OS, see + https://stackoverflow.com/questions/1133857/how-accurate-is-pythons-time-sleep + """ + now = get_now() + end = now + duration + while now < end: + now = get_now() + + +@pytest.mark.parametrize("max_runtime", [-0.001, -1, -10.1]) +def test_raise_negative_parameters(max_runtime: float): + """ + Maximum runtime may not be negative. + """ + with assert_raises(ValueError): + MaxRuntime(max_runtime) + + +@pytest.mark.parametrize("max_runtime", [0.001, 1, 10.1]) +def test_valid_parameters(max_runtime: float): + """ + Does not raise for non-negative parameters. + """ + MaxRuntime(max_runtime) + + +@pytest.mark.parametrize("max_runtime", [0.01, 0.1, 1]) +def test_max_runtime(max_runtime): + """ + Test if the max time parameter is correctly set. + """ + stop = MaxRuntime(max_runtime) + assert_(stop.max_runtime, max_runtime) + + +@pytest.mark.parametrize("max_runtime", [0.01, 0.05, 0.10]) +def test_before_max_runtime(max_runtime): + stop = MaxRuntime(max_runtime) + rnd = RandomState() + for _ in range(100): + assert_(not stop(rnd, Zero(), Zero())) + + +@pytest.mark.parametrize("max_runtime", [0.01, 0.05, 0.10]) +def test_after_max_runtime(max_runtime): + stop = MaxRuntime(max_runtime) + rnd = RandomState() + stop(rnd, Zero(), Zero()) # Trigger the first time measurement + sleep(max_runtime) + + for _ in range(100): + assert_(stop(rnd, Zero(), Zero())) diff --git a/alns/tests/test_alns.py b/alns/tests/test_alns.py index 714f6e98..67c0e2cc 100644 --- a/alns/tests/test_alns.py +++ b/alns/tests/test_alns.py @@ -5,6 +5,7 @@ from alns import ALNS, State from alns.criteria import HillClimbing, SimulatedAnnealing +from alns.stopping_criteria import MaxIterations, MaxRuntime from alns.weight_schemes import SimpleWeights from .states import One, Zero @@ -57,7 +58,7 @@ def test_on_best_is_called(): alns.on_best(lambda *args: ValueState(10)) weights = SimpleWeights([1, 1, 1, 1], 1, 1, .5) - result = alns.iterate(One(), weights, HillClimbing(), 1) + result = alns.iterate(One(), weights, HillClimbing(), MaxIterations(1)) assert_equal(result.best_state.objective(), 10) @@ -140,7 +141,7 @@ def test_raises_missing_destroy_operator(): weights = SimpleWeights([1, 1, 1, 1], 1, 1, 0.95) with assert_raises(ValueError): - alns.iterate(One(), weights, HillClimbing()) + alns.iterate(One(), weights, HillClimbing(), MaxIterations(1)) def test_raises_missing_repair_operator(): @@ -154,31 +155,48 @@ def test_raises_missing_repair_operator(): weights = SimpleWeights([1, 1, 1, 1], 1, 1, 0.95) with assert_raises(ValueError): - alns.iterate(One(), weights, HillClimbing()) + alns.iterate(One(), weights, HillClimbing(), MaxIterations(1)) -def test_raises_negative_iterations(): +def test_zero_max_iterations(): """ - The number of iterations should be non-negative, as zero is allowed. + Test that the algorithm return the initial solution when the + stopping criterion is zero max iterations. """ - alns = get_alns_instance([lambda state, rnd: None], - [lambda state, rnd: None]) + alns = get_alns_instance( + [lambda state, rnd: None], [lambda state, rnd: None] + ) initial_solution = One() - weights = SimpleWeights([1, 1, 1, 1], 1, 1, .5) + weights = SimpleWeights([1, 1, 1, 1], 1, 1, 0.5) - # A negative iteration count is not understood, for obvious reasons. - with assert_raises(ValueError): - alns.iterate(initial_solution, weights, HillClimbing(), -1) + result = alns.iterate( + initial_solution, weights, HillClimbing(), MaxIterations(0) + ) + + assert_(result.best_state is initial_solution) + + +def test_zero_max_runtime(): + """ + Test that the algorithm return the initial solution when the + stopping criterion is zero max runtime. + """ + alns = get_alns_instance( + [lambda state, rnd: None], [lambda state, rnd: None] + ) - # But zero should just return the initial solution. - result = alns.iterate(initial_solution, weights, HillClimbing(), 0) + initial_solution = One() + weights = SimpleWeights([1, 1, 1, 1], 1, 1, 0.5) + + result = alns.iterate( + initial_solution, weights, HillClimbing(), MaxRuntime(0) + ) assert_(result.best_state is initial_solution) def test_iterate_kwargs_are_correctly_passed_to_operators(): - def test_operator(state, rnd, item): assert_(item is orig_item) return state @@ -189,7 +207,7 @@ def test_operator(state, rnd, item): weights = SimpleWeights([1, 1, 1, 1], 1, 1, .5) orig_item = object() - alns.iterate(init_sol, weights, HillClimbing(), 10, item=orig_item) + alns.iterate(init_sol, weights, HillClimbing(), MaxIterations(10), item=orig_item) def test_bugfix_pass_kwargs_to_on_best(): @@ -197,6 +215,7 @@ def test_bugfix_pass_kwargs_to_on_best(): Exercises a bug where the on_best callback did not receive the kwargs passed to iterate(). """ + def test_operator(state, rnd, item): assert_(item is orig_item) return Zero() # better, so on_best is triggered @@ -205,10 +224,12 @@ def test_operator(state, rnd, item): alns.on_best(lambda state, rnd, item: state) init_sol = One() - weights = SimpleWeights([1, 1, 1, 1], 1, 1, .5) + weights = SimpleWeights([1, 1, 1, 1], 1, 1, 0.5) orig_item = object() - alns.iterate(init_sol, weights, HillClimbing(), 10, item=orig_item) + alns.iterate( + init_sol, weights, HillClimbing(), MaxIterations(10), item=orig_item + ) # EXAMPLES --------------------------------------------------------------------- @@ -219,11 +240,12 @@ def test_trivial_example(): This tests the ALNS algorithm on a trivial example, where the initial solution is one, and any other operator returns zero. """ - alns = get_alns_instance([lambda state, rnd: Zero()], - [lambda state, rnd: Zero()]) + alns = get_alns_instance( + [lambda state, rnd: Zero()], [lambda state, rnd: Zero()] + ) - weights = SimpleWeights([1, 1, 1, 1], 1, 1, .5) - result = alns.iterate(One(), weights, HillClimbing(), 100) + weights = SimpleWeights([1, 1, 1, 1], 1, 1, 0.5) + result = alns.iterate(One(), weights, HillClimbing(), MaxIterations(100)) assert_equal(result.best_state.objective(), 0) @@ -237,12 +259,58 @@ def test_fixed_seed_outcomes(seed: int, desired: float): alns = get_alns_instance( [lambda state, rnd: ValueState(rnd.random_sample())], [lambda state, rnd: None], - seed) + seed, + ) - weights = SimpleWeights([1, 1, 1, 1], 1, 1, .5) - sa = SimulatedAnnealing(1, .25, 1 / 100) + weights = SimpleWeights([1, 1, 1, 1], 1, 1, 0.5) + sa = SimulatedAnnealing(1, 0.25, 1 / 100) - result = alns.iterate(One(), weights, sa, 100) + result = alns.iterate(One(), weights, sa, MaxIterations(100)) assert_almost_equal(result.best_state.objective(), desired, decimal=5) + +@mark.parametrize("max_iterations", [1, 10, 100]) +def test_nonnegative_max_iterations(max_iterations): + """ + Test that the result statistics have size equal to max iterations (+1). + """ + alns = get_alns_instance( + [lambda state, rnd: Zero()], [lambda state, rnd: Zero()] + ) + + initial_solution = One() + weights = SimpleWeights([1, 1, 1, 1], 1, 1, 0.5) + + result = alns.iterate( + initial_solution, + weights, + HillClimbing(), + MaxIterations(max_iterations), + ) + + assert_equal(len(result.statistics.objectives), max_iterations + 1) + assert_equal(len(result.statistics.runtimes), max_iterations) + + +@mark.parametrize("max_runtime", [0.01, 0.05, 0.1]) +def test_nonnegative_max_runtime(max_runtime): + """ + Test that the result runtime statistics correspond to the stopping criterion. + """ + alns = get_alns_instance( + [lambda state, rnd: Zero()], [lambda state, rnd: Zero()] + ) + + initial_solution = One() + weights = SimpleWeights([1, 1, 1, 1], 1, 1, 0.5) + + result = alns.iterate( + initial_solution, weights, HillClimbing(), MaxRuntime(max_runtime) + ) + + assert_almost_equal( + sum(result.statistics.runtimes), max_runtime, decimal=3 + ) + + # TODO test more complicated examples? diff --git a/alns/tests/test_statistics.py b/alns/tests/test_statistics.py index 5efea1f2..f042c204 100644 --- a/alns/tests/test_statistics.py +++ b/alns/tests/test_statistics.py @@ -41,6 +41,31 @@ def test_collect_runtimes(): assert_allclose(statistics.runtimes, 1) # steps of one +def test_start_time(): + """ + Tests if the reference start time parameter is correctly set. + """ + statistics = Statistics() + + for time in range(1): + statistics.collect_runtime(time) + + assert_equal(statistics.start_time, 0) + + +def test_total_runtime(): + """ + Tests if the total runtime parameter is correctly set. + """ + statistics = Statistics() + + for time in range(100): + statistics.collect_runtime(time) + + assert_equal(statistics.total_runtime, 99) + + + def test_collect_destroy_counts_example(): """ Tests if collecting for a destroy operator works as expected in a simple