Skip to content

Commit 2550a14

Browse files
authored
Stopping criteria (#64)
* Base class for stopping criteria * Implemented MaxIterations and passed tests * Imeplemented MaxRuntime and passed tests * Refactor MaxIterations and MaxRuntime * Add start_time and total_runtime fields to Statistics
1 parent f478e0c commit 2550a14

File tree

11 files changed

+363
-50
lines changed

11 files changed

+363
-50
lines changed

alns/ALNS.py

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from alns.Statistics import Statistics
99
from alns.criteria import AcceptanceCriterion
1010
from alns.weight_schemes import WeightScheme
11+
from alns.stopping_criteria import StoppingCriterion
1112

1213
# Potential candidate solution consideration outcomes.
1314
_BEST = 0
@@ -21,7 +22,6 @@
2122

2223

2324
class ALNS:
24-
2525
def __init__(self, rnd_state: rnd.RandomState = rnd.RandomState()):
2626
"""
2727
Implements the adaptive large neighbourhood search (ALNS) algorithm.
@@ -107,12 +107,14 @@ def add_repair_operator(self, op: _OperatorType, name: str = None):
107107
"""
108108
self._repair_operators[name if name else op.__name__] = op
109109

110-
def iterate(self,
111-
initial_solution: State,
112-
weight_scheme: WeightScheme,
113-
crit: AcceptanceCriterion,
114-
iterations: int = 10_000,
115-
**kwargs) -> Result:
110+
def iterate(
111+
self,
112+
initial_solution: State,
113+
weight_scheme: WeightScheme,
114+
crit: AcceptanceCriterion,
115+
stop: StoppingCriterion,
116+
**kwargs,
117+
) -> Result:
116118
"""
117119
Runs the adaptive large neighbourhood search heuristic [1], using the
118120
previously set destroy and repair operators. The first solution is set
@@ -129,8 +131,10 @@ def iterate(self,
129131
crit
130132
The acceptance criterion to use for candidate states. See also
131133
the ``alns.criteria`` module for an overview.
132-
iterations
133-
The number of iterations. Default 10_000.
134+
stop
135+
The stopping criterion to use for stopping the iterations.
136+
See also the ``alns.stopping_criteria`` module for an overview.
137+
134138
**kwargs
135139
Optional keyword arguments. These are passed to the operators,
136140
including callbacks.
@@ -156,18 +160,17 @@ class of vehicle routing problems with backhauls. *European Journal of
156160
Operational Research*, 171: 750–775, 2006.
157161
"""
158162
if len(self.destroy_operators) == 0 or len(self.repair_operators) == 0:
159-
raise ValueError("Missing at least one destroy or repair operator.")
160-
161-
if iterations < 0:
162-
raise ValueError("Negative number of iterations.")
163+
raise ValueError(
164+
"Missing at least one destroy or repair operator."
165+
)
163166

164167
curr = best = initial_solution
165168

166169
stats = Statistics()
167170
stats.collect_objective(initial_solution.objective())
168171
stats.collect_runtime(time.perf_counter())
169172

170-
for iteration in range(iterations):
173+
while not stop(self._rnd_state, best, curr):
171174
d_idx, r_idx = weight_scheme.select_operators(self._rnd_state)
172175

173176
d_name, d_operator = self.destroy_operators[d_idx]
@@ -176,11 +179,9 @@ class of vehicle routing problems with backhauls. *European Journal of
176179
destroyed = d_operator(curr, self._rnd_state, **kwargs)
177180
cand = r_operator(destroyed, self._rnd_state, **kwargs)
178181

179-
best, curr, s_idx = self._eval_cand(crit,
180-
best,
181-
curr,
182-
cand,
183-
**kwargs)
182+
best, curr, s_idx = self._eval_cand(
183+
crit, best, curr, cand, **kwargs
184+
)
184185

185186
weight_scheme.update_weights(d_idx, r_idx, s_idx)
186187

@@ -206,12 +207,12 @@ def on_best(self, func: _OperatorType):
206207
self._on_best = func
207208

208209
def _eval_cand(
209-
self,
210-
crit: AcceptanceCriterion,
211-
best: State,
212-
curr: State,
213-
cand: State,
214-
**kwargs
210+
self,
211+
crit: AcceptanceCriterion,
212+
best: State,
213+
curr: State,
214+
cand: State,
215+
**kwargs,
215216
) -> Tuple[State, State, int]:
216217
"""
217218
Considers the candidate solution by comparing it against the best and

alns/Statistics.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ def objectives(self) -> np.ndarray:
2424
"""
2525
return np.array(self._objectives)
2626

27+
@property
28+
def start_time(self) -> float:
29+
"""
30+
Return the reference start time to compute the runtimes.
31+
"""
32+
return self._runtimes[0]
33+
34+
@property
35+
def total_runtime(self) -> float:
36+
"""
37+
Return the total runtime (in seconds).
38+
"""
39+
return self._runtimes[-1] - self._runtimes[0]
40+
2741
@property
2842
def runtimes(self) -> np.ndarray:
2943
"""
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from numpy.random import RandomState
2+
3+
from alns.State import State
4+
from alns.stopping_criteria.StoppingCriterion import StoppingCriterion
5+
6+
7+
class MaxIterations(StoppingCriterion):
8+
def __init__(self, max_iterations: int):
9+
"""
10+
Criterion that stops after a maximum number of iterations.
11+
"""
12+
if max_iterations < 0:
13+
raise ValueError("Max iterations must be non-negative.")
14+
15+
self._max_iterations = max_iterations
16+
self._current_iteration = 0
17+
18+
@property
19+
def max_iterations(self) -> int:
20+
return self._max_iterations
21+
22+
def __call__(self, rnd: RandomState, best: State, current: State) -> bool:
23+
self._current_iteration += 1
24+
25+
return self._current_iteration > self.max_iterations

alns/stopping_criteria/MaxRuntime.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import time
2+
3+
from typing import Optional
4+
from numpy.random import RandomState
5+
6+
from alns.State import State
7+
from alns.stopping_criteria.StoppingCriterion import StoppingCriterion
8+
9+
10+
class MaxRuntime(StoppingCriterion):
11+
def __init__(self, max_runtime: float):
12+
"""
13+
Criterion that stops after a specified maximum runtime.
14+
"""
15+
if max_runtime < 0:
16+
raise ValueError("Max runtime must be non-negative.")
17+
18+
self._max_runtime = max_runtime
19+
self._start_runtime: Optional[float] = None
20+
21+
@property
22+
def max_runtime(self) -> float:
23+
return self._max_runtime
24+
25+
def __call__(self, rnd: RandomState, best: State, current: State) -> bool:
26+
if self._start_runtime is None:
27+
self._start_runtime = time.perf_counter()
28+
29+
return time.perf_counter () - self._start_runtime > self.max_runtime
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from abc import ABC, abstractmethod
2+
3+
from numpy.random import RandomState
4+
5+
from alns.State import State
6+
7+
8+
class StoppingCriterion(ABC):
9+
"""
10+
Base class from which to implement a stopping criterion.
11+
"""
12+
13+
@abstractmethod
14+
def __call__(self, rnd: RandomState, best: State, current: State) -> bool:
15+
"""
16+
Determines whether to stop based on the implemented stopping criterion.
17+
18+
Parameters
19+
----------
20+
rnd
21+
May be used to draw random numbers from.
22+
best
23+
The best solution state observed so far.
24+
current
25+
The current solution state.
26+
27+
Returns
28+
-------
29+
Whether to stop the iteration (True), or not (False).
30+
"""
31+
return NotImplemented

alns/stopping_criteria/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .MaxIterations import MaxIterations
2+
from .MaxRuntime import MaxRuntime
3+
from .StoppingCriterion import StoppingCriterion

alns/stopping_criteria/tests/__init__.py

Whitespace-only changes.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import pytest
2+
3+
from numpy.random import RandomState
4+
from numpy.testing import assert_, assert_raises
5+
6+
from alns.stopping_criteria import MaxIterations
7+
from alns.tests.states import Zero
8+
9+
10+
@pytest.mark.parametrize("max_iterations", [-1, -42, -10000])
11+
def test_raise_negative_parameters(max_iterations: int):
12+
"""
13+
Maximum iterations cannot be negative.
14+
"""
15+
with assert_raises(ValueError):
16+
MaxIterations(max_iterations)
17+
18+
19+
@pytest.mark.parametrize("max_iterations", [1, 42, 10000])
20+
def test_does_not_raise(max_iterations: int):
21+
"""
22+
Valid parameters should not raise.
23+
"""
24+
MaxIterations(max_iterations)
25+
26+
27+
@pytest.mark.parametrize("max_iterations", [1, 42, 10000])
28+
def test_max_iterations(max_iterations):
29+
"""
30+
Test if the max iterations parameter is correctly set.
31+
"""
32+
stop = MaxIterations(max_iterations)
33+
assert stop.max_iterations == max_iterations
34+
35+
36+
def test_before_max_iterations():
37+
stop = MaxIterations(100)
38+
rnd = RandomState(0)
39+
40+
for _ in range(100):
41+
assert_(not stop(rnd, Zero(), Zero()))
42+
43+
44+
def test_after_max_iterations():
45+
stop = MaxIterations(100)
46+
rnd = RandomState()
47+
48+
for _ in range(100):
49+
stop(rnd, Zero(), Zero())
50+
51+
for _ in range(100):
52+
assert_(stop(rnd, Zero(), Zero()))
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import time
2+
import pytest
3+
4+
from numpy.random import RandomState
5+
from numpy.testing import assert_, assert_almost_equal, assert_raises
6+
7+
from alns.stopping_criteria import MaxRuntime
8+
from alns.tests.states import Zero
9+
10+
11+
def sleep(duration, get_now=time.perf_counter):
12+
"""
13+
Custom sleep function. Built-in time.sleep function is not precise
14+
and has different performances depending on the OS, see
15+
https://stackoverflow.com/questions/1133857/how-accurate-is-pythons-time-sleep
16+
"""
17+
now = get_now()
18+
end = now + duration
19+
while now < end:
20+
now = get_now()
21+
22+
23+
@pytest.mark.parametrize("max_runtime", [-0.001, -1, -10.1])
24+
def test_raise_negative_parameters(max_runtime: float):
25+
"""
26+
Maximum runtime may not be negative.
27+
"""
28+
with assert_raises(ValueError):
29+
MaxRuntime(max_runtime)
30+
31+
32+
@pytest.mark.parametrize("max_runtime", [0.001, 1, 10.1])
33+
def test_valid_parameters(max_runtime: float):
34+
"""
35+
Does not raise for non-negative parameters.
36+
"""
37+
MaxRuntime(max_runtime)
38+
39+
40+
@pytest.mark.parametrize("max_runtime", [0.01, 0.1, 1])
41+
def test_max_runtime(max_runtime):
42+
"""
43+
Test if the max time parameter is correctly set.
44+
"""
45+
stop = MaxRuntime(max_runtime)
46+
assert_(stop.max_runtime, max_runtime)
47+
48+
49+
@pytest.mark.parametrize("max_runtime", [0.01, 0.05, 0.10])
50+
def test_before_max_runtime(max_runtime):
51+
stop = MaxRuntime(max_runtime)
52+
rnd = RandomState()
53+
for _ in range(100):
54+
assert_(not stop(rnd, Zero(), Zero()))
55+
56+
57+
@pytest.mark.parametrize("max_runtime", [0.01, 0.05, 0.10])
58+
def test_after_max_runtime(max_runtime):
59+
stop = MaxRuntime(max_runtime)
60+
rnd = RandomState()
61+
stop(rnd, Zero(), Zero()) # Trigger the first time measurement
62+
sleep(max_runtime)
63+
64+
for _ in range(100):
65+
assert_(stop(rnd, Zero(), Zero()))

0 commit comments

Comments
 (0)