Repository: N-Wouda/ALNS

Issue:
Introduce RandomSelect scheme
Introduce a selection scheme that selects destroy and repair operators with uniform probability. To be done after introducing the new selection interface.

Comments:

Patch:
diff --git a/alns/select/OperatorSelectionScheme.py b/alns/select/OperatorSelectionScheme.py
index 78ddc6b..365f79d 100644
--- a/alns/select/OperatorSelectionScheme.py
+++ b/alns/select/OperatorSelectionScheme.py
@@ -18,7 +18,7 @@ class OperatorSelectionScheme(ABC):
     num_repair
         Number of repair operators.
     op_coupling
-        Optional boolean matrix that indicates coupling between destroy and
+        Optional 2D boolean matrix that indicates coupling between destroy and
         repair operators. Entry (i, j) is True if destroy operator i can be
         used together with repair operator j, and False otherwise.
     """
@@ -29,15 +29,17 @@ class OperatorSelectionScheme(ABC):
         num_repair: int,
         op_coupling: Optional[np.ndarray] = None,
     ):
+        if op_coupling is not None:
+            op_coupling = np.asarray(op_coupling, dtype=bool)
+            op_coupling = np.atleast_2d(op_coupling)
+        else:
+            op_coupling = np.ones((num_destroy, num_repair), dtype=bool)
+
         self._validate_arguments(num_destroy, num_repair, op_coupling)
 
         self._num_destroy = num_destroy
         self._num_repair = num_repair
-
-        if op_coupling is not None:
-            self._op_coupling = op_coupling.astype(bool)
-        else:
-            self._op_coupling = np.ones((num_destroy, num_repair), dtype=bool)
+        self._op_coupling = op_coupling
 
     @property
     def num_destroy(self) -> int:
@@ -78,8 +80,8 @@ class OperatorSelectionScheme(ABC):
     @abstractmethod
     def update(self, candidate: State, d_idx: int, r_idx: int, s_idx: int):
         """
-        Updates the weights associated with the applied destroy (d_idx) and
-        repair (r_idx) operators.
+        Updates the selection schame based on the outcome of the applied
+        destroy (d_idx) and repair (r_idx) operators.
 
         Parameters
         ----------
@@ -95,12 +97,17 @@ class OperatorSelectionScheme(ABC):
         return NotImplemented
 
     @staticmethod
-    def _validate_arguments(num_destroy, num_repair, op_coupling):
+    def _validate_arguments(
+        num_destroy: int, num_repair: int, op_coupling: np.ndarray
+    ):
         if num_destroy <= 0 or num_repair <= 0:
             raise ValueError("Missing destroy or repair operators.")
 
-        if op_coupling is None:
-            return
+        if op_coupling.shape != (num_destroy, num_repair):
+            raise ValueError(
+                f"Coupling matrix of shape {op_coupling.shape}, expected "
+                f"{(num_destroy, num_repair)}."
+            )
 
         # Destroy ops. must be coupled with at least one repair operator
         d_idcs = np.flatnonzero(np.count_nonzero(op_coupling, axis=1) == 0)
diff --git a/alns/select/RandomSelect.py b/alns/select/RandomSelect.py
new file mode 100644
index 0000000..9a7dd2b
--- /dev/null
+++ b/alns/select/RandomSelect.py
@@ -0,0 +1,22 @@
+import numpy as np
+
+from alns.select.OperatorSelectionScheme import OperatorSelectionScheme
+
+
+class RandomSelect(OperatorSelectionScheme):
+    """
+    Randomly selects operator pairs with uniform probability. The operator
+    pairs respect the operator coupling matrix.
+    """
+
+    def __call__(self, rnd, best, curr):
+        """
+        Selects a (destroy, repair) operator pair with uniform probability.
+        """
+        allowed = np.argwhere(self._op_coupling)
+        idx = rnd.randint(len(allowed))
+
+        return tuple(allowed[idx])
+
+    def update(self, candidate, d_idx, r_idx, s_idx):
+        pass  # pragma: no cover
diff --git a/alns/select/__init__.py b/alns/select/__init__.py
index 8161c7f..9b405bc 100644
--- a/alns/select/__init__.py
+++ b/alns/select/__init__.py
@@ -1,4 +1,5 @@
 from .AlphaUCB import AlphaUCB
 from .OperatorSelectionScheme import OperatorSelectionScheme
+from .RandomSelect import RandomSelect
 from .RouletteWheel import RouletteWheel
 from .SegmentedRouletteWheel import SegmentedRouletteWheel
diff --git a/docs/source/select.rst b/docs/source/select.rst
index 55b2403..63e5d41 100644
--- a/docs/source/select.rst
+++ b/docs/source/select.rst
@@ -16,6 +16,9 @@ All operator selection schemes inherit from :class:`~alns.select.OperatorSelecti
 .. automodule:: alns.select.AlphaUCB
    :members:
 
+.. automodule:: alns.select.RandomSelect
+   :members:
+
 .. automodule:: alns.select.RouletteWheel
    :members:
 


Test patch:
diff --git a/alns/select/tests/test_random_select.py b/alns/select/tests/test_random_select.py
new file mode 100644
index 0000000..ffed712
--- /dev/null
+++ b/alns/select/tests/test_random_select.py
@@ -0,0 +1,70 @@
+import numpy as np
+import numpy.random as rnd
+from numpy.testing import assert_, assert_allclose, assert_approx_equal
+
+from alns.select import RandomSelect
+from alns.tests.states import Zero
+
+
+def test_op_coupling():
+    rnd_state = rnd.RandomState(1)
+
+    # For i in {1..5}, each destroy operator i is coupled with repair operator
+    # i. So only (i, i) pairs can be selected.
+    op_coupling = np.eye(5)
+    select = RandomSelect(5, 5, op_coupling)
+
+    for _ in range(1_000):
+        d_idx, r_idx = select(rnd_state, Zero(), Zero())
+        assert_(d_idx == r_idx)
+
+
+def test_uniform_selection():
+    rnd_state = rnd.RandomState(1)
+    histogram = np.zeros((2, 2))
+
+    select = RandomSelect(2, 2)
+
+    for _ in range(10_000):
+        d_idx, r_idx = select(rnd_state, Zero(), Zero())
+        histogram[d_idx, r_idx] += 1
+
+    # There are four operator pair combinations, so each pair should have a
+    # one in four chance of being selected. We allow a 0.01 margin since this
+    # is based on sampling.
+    histogram /= histogram.sum()
+    assert_allclose(histogram, 0.25, atol=0.01)
+
+
+def test_uniform_selection_op_coupling():
+    rnd_state = rnd.RandomState(1)
+    histogram = np.zeros((2, 2))
+
+    op_coupling = np.eye(2)
+    op_coupling[0, 1] = 1
+
+    select = RandomSelect(2, 2, op_coupling)
+
+    for _ in range(10_000):
+        d_idx, r_idx = select(rnd_state, Zero(), Zero())
+        histogram[d_idx, r_idx] += 1
+
+    # There are three OK operator pair combinations, so each such pair should
+    # have a one in three chance of being selected.
+    histogram /= histogram.sum()
+
+    # These should be sampled uniformly...
+    assert_approx_equal(histogram[0, 0], 1 / 3, significant=2)
+    assert_approx_equal(histogram[0, 1], 1 / 3, significant=2)
+    assert_approx_equal(histogram[1, 1], 1 / 3, significant=2)
+
+    # ...but this one's not allowed by the operator coupling matrix.
+    assert_approx_equal(histogram[1, 0], 0, significant=7)
+
+
+def test_single_operators():
+    rnd_state = rnd.RandomState(1)
+    select = RandomSelect(1, 1)
+
+    # Only one (destroy, repair) operator pair, so should return (0, 0).
+    assert_(select(rnd_state, Zero(), Zero()) == (0, 0))
diff --git a/alns/select/tests/test_roulette_wheel.py b/alns/select/tests/test_roulette_wheel.py
index 6e3b73a..2b43f21 100644
--- a/alns/select/tests/test_roulette_wheel.py
+++ b/alns/select/tests/test_roulette_wheel.py
@@ -105,3 +105,21 @@ def test_raise_uncoupled_destroy_op(op_coupling):
         RouletteWheel(
             [0, 0, 0, 0], 0, n_destroy, n_repair, op_coupling=op_coupling
         )
+
+
+@mark.parametrize(
+    "n_destroy, n_repair, op_coupling",
+    [
+        (1, 2, [0]),  # missing repair column
+        (2, 2, [0, 0]),  # missing destroy row
+        (2, 1, [0, 0]),  # too many repair, too few destroy
+    ],
+)
+def test_raises_wrong_op_coupling_shape(n_destroy, n_repair, op_coupling):
+    with assert_raises(ValueError):
+        RouletteWheel([0, 0, 0, 0], 0, n_destroy, n_repair, op_coupling)
+
+
+def test_single_destroy_operator_coerces_coupling_matrix():
+    select = RouletteWheel([0, 0, 0, 0], 0, 1, 2, [1, 0])
+    assert_equal(select.op_coupling.shape, (1, 2))
diff --git a/alns/tests/test_alns.py b/alns/tests/test_alns.py
index d3df5c1..1ddd6d3 100644
--- a/alns/tests/test_alns.py
+++ b/alns/tests/test_alns.py
@@ -346,7 +346,7 @@ def test_nonnegative_max_runtime(max_runtime):
     )
 
     assert_almost_equal(
-        sum(result.statistics.runtimes), max_runtime, decimal=3
+        sum(result.statistics.runtimes), max_runtime, decimal=2
     )