Repository: N-Wouda/ALNS

Issue:
Add linear updating method to SimulatedAnnealing.autofit
`SimulatedAnnealing.autofit` assumes that the final temperature always goes to 1:

https://github.com/N-Wouda/ALNS/blob/8ef4665c9cb27923497e11dccf3e75d9b58821bd/alns/accept/SimulatedAnnealing.py#L104-L128

It would be nice to have an `end_worse` argument for simulated annealing, so that we can have final temperatures other than 1. The current `worse` parameter could rephrased to `start_worse`. 
- Issue: I don't know if there is a good default value for `start_worse` to make this a non-breaking change

It would also be nice to have an `method` argument to choose which updating method should be used. The current implementation assumes exponential updating. This is definitely not breaking.

For reference, RRT already has the two above features:
https://github.com/N-Wouda/ALNS/blob/8ef4665c9cb27923497e11dccf3e75d9b58821bd/alns/accept/RecordToRecordTravel.py#L109-L167

Comments:

Patch:
diff --git a/alns/ALNS.py b/alns/ALNS.py
index 3ee933d..0154577 100644
--- a/alns/ALNS.py
+++ b/alns/ALNS.py
@@ -176,7 +176,7 @@ class ALNS:
 
         .. [2] S. Røpke and D. Pisinger (2006). A unified heuristic for a large
                class of vehicle routing problems with backhauls. *European
-               Journal of Operational Research*, 171: 750–775, 2006.
+               Journal of Operational Research*, 171: 750–775.
         """
         if len(self.destroy_operators) == 0 or len(self.repair_operators) == 0:
             raise ValueError("Missing destroy or repair operators.")
diff --git a/alns/accept/RecordToRecordTravel.py b/alns/accept/RecordToRecordTravel.py
index 2a6604e..7474d0f 100644
--- a/alns/accept/RecordToRecordTravel.py
+++ b/alns/accept/RecordToRecordTravel.py
@@ -135,7 +135,13 @@ class RecordToRecordTravel(AcceptanceCriterion):
         num_iters
             The number of iterations that the ALNS algorithm will run.
         method
-            Updating method. Default 'linear'.
+            The updating method, one of {'linear', 'exponential'}. Default
+            'linear'.
+
+        Raises
+        ------
+        ValueError
+            When the parameters do not meet requirements.
 
         Returns
         -------
@@ -156,11 +162,11 @@ class RecordToRecordTravel(AcceptanceCriterion):
 
         if method == "linear":
             step = (start_threshold - end_threshold) / num_iters
-        elif method == "exponential":
+        else:
             step = (end_threshold / start_threshold) ** (1 / num_iters)
 
         logger.info(
-            f"Autofit start_threshold {start_threshold:.2f}"
+            f"Autofit {method} RRT: start_threshold {start_threshold:.2f}, "
             f"end_threshold {end_threshold:.2f}, step {step:.2f}."
         )
 
diff --git a/alns/accept/SimulatedAnnealing.py b/alns/accept/SimulatedAnnealing.py
index 142f6a1..ba57ee1 100644
--- a/alns/accept/SimulatedAnnealing.py
+++ b/alns/accept/SimulatedAnnealing.py
@@ -102,7 +102,12 @@ class SimulatedAnnealing(AcceptanceCriterion):
 
     @classmethod
     def autofit(
-        cls, init_obj: float, worse: float, accept_prob: float, num_iters: int
+        cls,
+        init_obj: float,
+        worse: float,
+        accept_prob: float,
+        num_iters: int,
+        method: str = "exponential",
     ) -> "SimulatedAnnealing":
         """
         Returns an SA object with initial temperature such that there is a
@@ -118,20 +123,22 @@ class SimulatedAnnealing(AcceptanceCriterion):
         init_obj
             The initial solution objective.
         worse
-            Percentage (between 0 and 1) the candidate solution may be worse
-            than initial solution for it to be accepted with probability
+            Percentage (in (0, 1), exclusive) the candidate solution may be
+            worse than initial solution for it to be accepted with probability
             ``accept_prob``.
         accept_prob
-            Initial acceptance probability for a solution at most ``worse``
-            worse than the initial solution.
+            Initial acceptance probability (in [0, 1]) for a solution at most
+            ``worse`` worse than the initial solution.
         num_iters
             Number of iterations the ALNS algorithm will run.
+        method
+            The updating method, one of {'linear', 'exponential'}. Default
+            'exponential'.
 
         Raises
         ------
         ValueError
-            When ``worse`` not in [0, 1] or when ``accept_prob`` is not in
-            (0, 1).
+            When the parameters do not meet requirements.
 
         Returns
         -------
@@ -154,12 +161,22 @@ class SimulatedAnnealing(AcceptanceCriterion):
         if not (0 < accept_prob < 1):
             raise ValueError("accept_prob outside (0, 1) not understood.")
 
-        if num_iters < 0:
-            raise ValueError("Negative number of iterations not understood.")
+        if num_iters <= 0:
+            raise ValueError("Non-positive num_iters not understood.")
+
+        if method not in ["linear", "exponential"]:
+            raise ValueError("Method must be one of ['linear', 'exponential']")
 
         start_temp = -worse * init_obj / np.log(accept_prob)
-        step = (1 / start_temp) ** (1 / num_iters)
 
-        logger.info(f"Autofit start_temp {start_temp:.2f}, step {step:.2f}.")
+        if method == "linear":
+            step = (start_temp - 1) / num_iters
+        else:
+            step = (1 / start_temp) ** (1 / num_iters)
+
+        logger.info(
+            f"Autofit {method} SA: start_temp {start_temp:.2f}, "
+            f"step {step:.2f}."
+        )
 
-        return cls(start_temp, 1, step, method="exponential")
+        return cls(start_temp, 1, step, method=method)


Test patch:
diff --git a/alns/accept/tests/test_simulated_annealing.py b/alns/accept/tests/test_simulated_annealing.py
index 46998aa..f7742db 100644
--- a/alns/accept/tests/test_simulated_annealing.py
+++ b/alns/accept/tests/test_simulated_annealing.py
@@ -65,7 +65,10 @@ def test_does_not_raise():
     These sets of parameters should work correctly.
     """
     SimulatedAnnealing(10, 5, 1, "exponential")
+    SimulatedAnnealing(10, 5, 1, "EXPONENTIAL")
+
     SimulatedAnnealing(10, 5, 2, "linear")
+    SimulatedAnnealing(10, 5, 2, "LINEAR")
 
 
 @mark.parametrize("step", range(10))
@@ -163,22 +166,24 @@ def test_accepts_generator_and_random_state():
 
 
 @mark.parametrize(
-    "worse,accept_prob,iters",
+    "worse, accept_prob, iters, method",
     [
-        (1, 0, 10),  # zero accept prob
-        (1, 1.2, 10),  # prob outside unit interval
-        (1, 1, 10),  # unit accept prob
-        (-1, 0.5, 10),  # negative worse
-        (0, -1, 10),  # negative prob
-        (1.5, 0.5, 10),  # worse outside unit interval
-        (1, 0.9, -10),
+        (1, 0, 10, "exponential"),  # zero accept prob
+        (1, 1.2, 10, "exponential"),  # prob outside unit interval
+        (1, 1, 10, "exponential"),  # unit accept prob
+        (-1, 0.5, 10, "exponential"),  # negative worse
+        (0, -1, 10, "exponential"),  # negative prob
+        (1.5, 0.5, 10, "exponential"),  # worse outside unit interval
+        (1, 0.9, -10, "exponential"),  # negative iterations
+        (1, 0.9, 0, "exponential"),  # zero iterations
+        (1, 0.9, 10, "abc"),  # unknown method
     ],
-)  # negative number of iterations
+)
 def test_autofit_raises_for_invalid_inputs(
-    worse: float, accept_prob: float, iters: int
+    worse: float, accept_prob: float, iters: int, method: str
 ):
     with assert_raises(ValueError):
-        SimulatedAnnealing.autofit(1.0, worse, accept_prob, iters)
+        SimulatedAnnealing.autofit(1.0, worse, accept_prob, iters, method)
 
 
 @mark.parametrize(
@@ -206,3 +211,14 @@ def test_autofit_on_several_examples(
     assert_almost_equal(sa.end_temperature, sa_end)
     assert_almost_equal(sa.step, sa_step)
     assert_equal(sa.method, "exponential")
+
+
+def test_linear_autofit():
+    sa = SimulatedAnnealing.autofit(100, 0.05, 0.5, 100, "linear")
+    sa_start = -0.05 * 100 / np.log(0.5)
+    sa_step = (sa_start - 1) / 100
+
+    assert_almost_equal(sa.start_temperature, sa_start)
+    assert_almost_equal(sa.end_temperature, 1)
+    assert_almost_equal(sa.step, sa_step)
+    assert_equal(sa.method, "linear")