diff --git a/moptipy/operators/bitstrings/op1_flip_m.py b/moptipy/operators/bitstrings/op1_flip_m.py index 2537c6344..334a61f9c 100644 --- a/moptipy/operators/bitstrings/op1_flip_m.py +++ b/moptipy/operators/bitstrings/op1_flip_m.py @@ -80,9 +80,8 @@ def op1(self, random: Generator, dest: np.ndarray, x: np.ndarray, """ np.copyto(dest, x) # copy source to destination n: Final[int] = len(dest) # get the number of bits - flips: Final[np.ndarray] = random.choice( # choose the bits - n, exponential_step_size(step_size, 1, n), False) - dest[flips] ^= True # flip the selected bits via xor + dest[random.choice(n, exponential_step_size( + step_size, 1, n), False)] ^= True # flip the selected bits via xor def __str__(self) -> str: """ diff --git a/moptipy/operators/bitstrings/op1_m_over_n_flip.py b/moptipy/operators/bitstrings/op1_m_over_n_flip.py index 2698007d3..79dc0366c 100644 --- a/moptipy/operators/bitstrings/op1_m_over_n_flip.py +++ b/moptipy/operators/bitstrings/op1_m_over_n_flip.py @@ -1,6 +1,7 @@ """A unary operator flipping each bit with probability m/n.""" -from typing import Callable, Final +from typing import Final +import numba # type: ignore import numpy as np from numpy.random import Generator from pycommons.types import check_int_range, type_error @@ -12,6 +13,46 @@ ) +@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) +def _op1_movern(m: int, none_is_ok: bool, permutation: np.ndarray, + random: Generator, dest: np.ndarray, x: np.ndarray) -> None: + """ + Copy `x` into `dest` and flip each bit with probability m/n. + + This method will first copy `x` to `dest`. Then it will flip each bit + in `dest` with probability `m/n`, where `n` is the length of `dest`. + Regardless of the probability, at least one bit will always be + flipped if self.at_least_1 is True. + + :param m: the value of m + :param none_is_ok: is it OK to flip nothing? + :param permutation: the internal permutation + :param random: the random number generator + :param dest: the destination array to receive the new point + :param x: the existing point in the search space + """ + dest[:] = x[:] # copy source to destination + length: Final[int] = len(dest) # get n + p: Final[float] = m / length # probability to flip bit + + flips: int # the number of bits to flip + while True: + flips = random.binomial(length, p) # get the number of bits to flip + if flips > 0: + break # we will flip some bit + if none_is_ok: + return # we will flip no bit + + i: int = length + end: Final[int] = length - flips + while i > end: # we iterate from i=length down to end=length-flips + k = random.integers(0, i) # index of next bit index in permutation + i -= 1 # decrease i + idx = permutation[k] # get index of bit to flip and move to end + permutation[i], permutation[k] = idx, permutation[i] + dest[idx] = not dest[idx] # flip bit + + class Op1MoverNflip(Op1): """ A unary search operation that flips each bit with probability of `m/n`. @@ -61,30 +102,8 @@ def op1(self, random: Generator, dest: np.ndarray, x: np.ndarray) -> None: :param dest: the destination array to receive the new point :param x: the existing point in the search space """ - np.copyto(dest, x) # copy source to destination - length: Final[int] = len(dest) # get n - p: Final[float] = self.__m / length # probability to flip bit - none_is_ok: Final[bool] = self.__none_is_ok - - flips: int # the number of bits to flip - rbi: Final[Callable[[int, float], int]] = random.binomial - while True: - flips = rbi(length, p) # compute the number of bits to flip - if flips > 0: - break # we will flip some bit - if none_is_ok: - return # we will flip no bit - - permutation: Final[np.ndarray] = self.__permutation - i: int = length - end: Final[int] = length - flips - ri: Final[Callable[[int], int]] = random.integers - while i > end: # we iterate from i=length down to end=length-flips - k = ri(i) # get index of next bit index in permutation - i -= 1 # decrease i - idx = permutation[k] # get index of bit to flip and move to end - permutation[i], permutation[k] = idx, permutation[i] - dest[idx] = not dest[idx] # flip bit + _op1_movern(self.__m, self.__none_is_ok, self.__permutation, + random, dest, x) def __str__(self) -> str: """ diff --git a/moptipy/operators/permutations/op1_insert1.py b/moptipy/operators/permutations/op1_insert1.py index 5fdc10059..f7c53aa66 100644 --- a/moptipy/operators/permutations/op1_insert1.py +++ b/moptipy/operators/permutations/op1_insert1.py @@ -59,7 +59,7 @@ 2016, Denver, CO, USA, pages 57-58, New York, NY, USA: ACM. ISBN: 978-1-4503-4323-7. https://doi.org/10.1145/2908961.2909001 """ -from typing import Callable, Final +from typing import Final import numba # type: ignore import numpy as np @@ -69,144 +69,30 @@ @numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) -def try_single_rotate(arr: np.ndarray, i1: int, i2: int) -> bool: # +book +def rotate(random: Generator, dest: np.ndarray, x: np.ndarray) -> None: """ - Rotate a portion of an array to the left or right in place. + Copy `x` into `dest` and then rotate a subsequence by one step. + + The function repeatedly tries to rotate a portion of an array to the left + or right in place. It will continue trying until something changed. + In each step, it draws two indices `i1` and `i2`. If `i1 < i2`, then a left rotation by one step is performed. In other words, the element at index `i1 + 1` goes to index `i1`, the element at index `i1 + 2` goes to index `i1 + 1`, and so on. The lst element, i.e., the one at index `i2` goes to index `i2 - 1`. Finally, the element that originally was at index `i1` goes to index `i2`. If any element in the - array has changed, this function returns `False`, otherwise `True`. + array has changed, this function is done, otherwise it tries again. If `i1 > i2`, then a right rotation by one step is performed. In other words, the element at index `i1 - 1` goes to index `i1`, the element at index `i1 - 2` goes to index `i1 - 1`, and so on. Finally, the element that originally was at index `i1` goes to index `i2`. If any element in - the array has changed, this function returns `False`, otherwise `True`. + the array has changed, this function tries again, otherwise it stops. This corresponds to extracting the element at index `i1` and re-inserting it at index `i2`. - :param arr: the array to rotate - :param i1: the start index, in `0..len(arr)-1` - :param i2: the end index, in `0..len(arr)-1` - :returns: whether the array was *unchanged* - :retval False: if the array `arr` is now different from before - :retval True: if the array `arr` has not changed - - >>> import numpy as npx - >>> dest = npx.array(range(10)) - >>> print(dest) - [0 1 2 3 4 5 6 7 8 9] - >>> try_single_rotate(dest, 3, 4) - False - >>> print(dest) - [0 1 2 4 3 5 6 7 8 9] - >>> try_single_rotate(dest, 3, 4) - False - >>> print(dest) - [0 1 2 3 4 5 6 7 8 9] - >>> try_single_rotate(dest, 4, 3) - False - >>> print(dest) - [0 1 2 4 3 5 6 7 8 9] - >>> try_single_rotate(dest, 4, 3) - False - >>> print(dest) - [0 1 2 3 4 5 6 7 8 9] - >>> try_single_rotate(dest, 3, 6) - False - >>> print(dest) - [0 1 2 4 5 6 3 7 8 9] - >>> try_single_rotate(dest, 6, 3) - False - >>> print(dest) - [0 1 2 3 4 5 6 7 8 9] - >>> try_single_rotate(dest, 0, len(dest) - 1) - False - >>> print(dest) - [1 2 3 4 5 6 7 8 9 0] - >>> try_single_rotate(dest, len(dest) - 1, 0) - False - >>> print(dest) - [0 1 2 3 4 5 6 7 8 9] - >>> try_single_rotate(dest, 7, 7) - True - >>> dest = np.array([0, 1, 2, 3, 3, 3, 3, 3, 8, 9]) - >>> try_single_rotate(dest, 7, 7) - True - >>> try_single_rotate(dest, 4, 6) - True - >>> print(dest) - [0 1 2 3 3 3 3 3 8 9] - >>> try_single_rotate(dest, 6, 4) - True - >>> print(dest) - [0 1 2 3 3 3 3 3 8 9] - >>> try_single_rotate(dest, 4, 7) - True - >>> print(dest) - [0 1 2 3 3 3 3 3 8 9] - >>> try_single_rotate(dest, 6, 7) - True - >>> print(dest) - [0 1 2 3 3 3 3 3 8 9] - >>> try_single_rotate(dest, 4, 8) - False - >>> print(dest) - [0 1 2 3 3 3 3 8 3 9] - >>> try_single_rotate(dest, 8, 4) - False - >>> print(dest) - [0 1 2 3 3 3 3 3 8 9] - >>> try_single_rotate(dest, 9, 4) - False - >>> print(dest) - [0 1 2 3 9 3 3 3 3 8] - >>> try_single_rotate(dest, 4, 9) - False - >>> print(dest) - [0 1 2 3 3 3 3 3 8 9] - """ - # start book - if i1 == i2: # nothing to be done - return True # array will not be changed - - unchanged: bool = True # initially, assume that there is no change - - if i1 < i2: # rotate to the left: move elements to lower indices? - first = arr[i1] # get the element to be removed - while i1 < i2: # iterate the indices - i3 = i1 + 1 # get next higher index - cpy = arr[i3] # get next element at that higher index - unchanged = unchanged and (cpy == arr[i1]) # is a change? - arr[i1] = cpy # store next element at the lower index - i1 = i3 # move to next higher index - unchanged = unchanged and (first == arr[i2]) # check if change - arr[i2] = first # store removed element at highest index - return unchanged # return True if something changed, else False - - last = arr[i1] # last element; rotate right: move elements up - while i2 < i1: # iterate over indices - i3 = i1 - 1 # get next lower index - cpy = arr[i3] # get element at that lower index - unchanged = unchanged and (cpy == arr[i1]) # is a change? - arr[i1] = cpy # store element at higher index - i1 = i3 # move to next lower index - unchanged = unchanged and (last == arr[i2]) # check if change - arr[i2] = last # store removed element at lowest index - return unchanged # return True if something changed, else False -# end book - - -# Temporary fix for https://github.com/numba/numba/issues/9103 -def rotate(random: Generator, dest: np.ndarray, # +book - x: np.ndarray) -> None: # +book - """ - Copy `x` into `dest` and then rotate a subsequence by one step. - :param random: the random number generator :param dest: the array to receive the modified copy of `x` :param x: the existing point in the search space @@ -227,14 +113,37 @@ def rotate(random: Generator, dest: np.ndarray, # +book >>> print(out) [0 1 2 3 4 8 5 6 7 9] """ - # start book dest[:] = x[:] length: Final[int] = len(dest) # Get the length of `dest`. - rint: Callable[[int, int], int] = random.integers # fast call - + unchanged: bool = True # try to rotate the dest array until something changes - while try_single_rotate(dest, rint(0, length), rint(0, length)): - pass # do nothing in the loop, but try rotating again + while unchanged: + i1: int = random.integers(0, length) + i2: int = random.integers(0, length) + if i1 == i2: # nothing to be done + continue # array will not be changed + + if i1 < i2: # rotate to the left: move elements to lower indices? + first = dest[i1] # get the element to be removed + while i1 < i2: # iterate the indices + i3 = i1 + 1 # get next higher index + cpy = dest[i3] # get next element at that higher index + unchanged &= (cpy == dest[i1]) # is a change? + dest[i1] = cpy # store next element at the lower index + i1 = i3 # move to next higher index + unchanged &= (first == dest[i2]) # check if change + dest[i2] = first # store removed element at highest index + continue + + last = dest[i1] # last element; rotate right: move elements up + while i2 < i1: # iterate over indices + i3 = i1 - 1 # get next lower index + cpy = dest[i3] # get element at that lower index + unchanged &= (cpy == dest[i1]) # is a change? + dest[i1] = cpy # store element at higher index + i1 = i3 # move to next lower index + unchanged &= (last == dest[i2]) # check if change + dest[i2] = last # store removed element at lowest index class Op1Insert1(Op1): @@ -254,7 +163,6 @@ def __init__(self) -> None: """Initialize the object.""" super().__init__() self.op1 = rotate # type: ignore # use function directly -# end book def __str__(self) -> str: """ diff --git a/moptipy/operators/permutations/op1_swap2.py b/moptipy/operators/permutations/op1_swap2.py index ca9bd8bda..55381a6a1 100644 --- a/moptipy/operators/permutations/op1_swap2.py +++ b/moptipy/operators/permutations/op1_swap2.py @@ -48,13 +48,14 @@ """ from typing import Callable, Final +import numba # type: ignore import numpy as np from numpy.random import Generator from moptipy.api.operators import Op1 -# Temporary fix for https://github.com/numba/numba/issues/9103 +@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) def swap_2(random: Generator, dest: np.ndarray, # +book x: np.ndarray) -> None: # +book """ diff --git a/moptipy/operators/permutations/op1_swap_exactly_n.py b/moptipy/operators/permutations/op1_swap_exactly_n.py index a7bdbad4e..0992b538a 100644 --- a/moptipy/operators/permutations/op1_swap_exactly_n.py +++ b/moptipy/operators/permutations/op1_swap_exactly_n.py @@ -118,6 +118,7 @@ """ from typing import Counter, Final, Iterable +import numba # type: ignore import numpy as np from numpy.random import Generator from pycommons.types import check_int_range, type_error @@ -239,7 +240,7 @@ def get_max_changes(blueprint: Iterable[int]) -> int: return changes -# Temporary fix for https://github.com/numba/numba/issues/9103 +@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) def find_move(x: np.ndarray, indices: np.ndarray, step_size: int, random: Generator, max_trials: int, temp: np.ndarray) -> np.ndarray: @@ -402,7 +403,7 @@ def find_move(x: np.ndarray, indices: np.ndarray, step_size: int, return temp[0:best_size] -# Temporary fix for https://github.com/numba/numba/issues/9103 +@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) def apply_move(x: np.ndarray, dest: np.ndarray, move: np.ndarray, random: Generator, max_trials: int) -> None: """ diff --git a/moptipy/operators/permutations/op1_swap_try_n.py b/moptipy/operators/permutations/op1_swap_try_n.py index fe09f6338..b2766c7a5 100644 --- a/moptipy/operators/permutations/op1_swap_try_n.py +++ b/moptipy/operators/permutations/op1_swap_try_n.py @@ -18,6 +18,7 @@ """ from typing import Final +import numba # type: ignore import numpy as np from numpy.random import Generator from pycommons.types import type_error @@ -28,7 +29,7 @@ from moptipy.utils.nputils import DEFAULT_INT, fill_in_canonical_permutation -# Temporary fix for https://github.com/numba/numba/issues/9103 +@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) def swap_try_n(random: Generator, dest: np.ndarray, x: np.ndarray, step_size: float, indices: np.ndarray) -> None: """ diff --git a/moptipy/operators/permutations/op1_swapn.py b/moptipy/operators/permutations/op1_swapn.py index c9d01568c..1385057b4 100644 --- a/moptipy/operators/permutations/op1_swapn.py +++ b/moptipy/operators/permutations/op1_swapn.py @@ -49,13 +49,14 @@ """ from typing import Callable, Final +import numba # type: ignore import numpy as np from numpy.random import Generator from moptipy.api.operators import Op1 -# Temporary fix for https://github.com/numba/numba/issues/9103 +@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) def swap_n(random: Generator, dest: np.ndarray, # +book x: np.ndarray) -> None: # +book """ diff --git a/moptipy/operators/permutations/op2_gap.py b/moptipy/operators/permutations/op2_gap.py index 7a875b043..b20fe6f58 100644 --- a/moptipy/operators/permutations/op2_gap.py +++ b/moptipy/operators/permutations/op2_gap.py @@ -46,7 +46,6 @@ @numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) -# start book def _op2_gap(r: np.ndarray, dest: np.ndarray, x0: np.ndarray, x1: np.ndarray, x0_done: np.ndarray, x1_done: np.ndarray) -> None: @@ -90,10 +89,8 @@ def _op2_gap(r: np.ndarray, dest: np.ndarray, x1i = x1i + 1 # value in x1 dest[desti] = x0[x0i] # = x1[x1i]: the final missing value -# end book -# start book class Op2GeneralizedAlternatingPosition(Op2): """A binary operator trying to preserve the value sequence.""" @@ -109,7 +106,6 @@ def op2(self, random: Generator, dest: np.ndarray, """ _op2_gap(random.integers(low=2, high=None, size=len(dest) - 1), dest, x0, x1, self.__x0_done, self.__x1_done) - # end book def __init__(self, space: Permutations) -> None: """ diff --git a/moptipy/operators/permutations/op2_ox2.py b/moptipy/operators/permutations/op2_ox2.py index 0b89f045f..b12cc0b7c 100644 --- a/moptipy/operators/permutations/op2_ox2.py +++ b/moptipy/operators/permutations/op2_ox2.py @@ -35,8 +35,9 @@ 13(2):129-170, April 1999. Kluwer Academic Publishers, The Netherlands. https://doi.org/10.1023/A:1006529012972 """ -from typing import Callable, Final +from typing import Final +import numba # type: ignore import numpy as np from numpy.random import Generator from pycommons.types import type_error @@ -50,7 +51,60 @@ ) -# start book +@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) +def _op2_ox2(indices: np.ndarray, + x1_done: np.ndarray, + random: Generator, dest: np.ndarray, + x0: np.ndarray, x1: np.ndarray) -> None: + """ + Apply the order-based crossover from `x0` and `x1` to `dest`. + + :param indices: the indices + :param x1_done: the elements of x1 that are done + :param random: the random number generator + :param dest: the array to receive the result + :param x0: the first existing point in the search space + :param x1: the second existing point in the search space + """ + x1_done.fill(False) # all values in x1 are available + length: Final[int] = len(indices) # get length of string + copy_from_x0: int # the end index of copying from x0 + value: int # the current value to be written to dest + + # start book + while True: # sample the number of values to copy from x0 + copy_from_x0 = random.binomial(length, 0.5) # p=0.5 for each value + if 1 < copy_from_x0 < (length - 1): # ensure difference by + break # copying at least two values from each parent + copy_from_x0 = length - copy_from_x0 # compute end index-index + + i: int = length # the index into indices we iterate over + mode: bool = True # mode: True = copy from x0, False = from x1 + x1i: int = 0 # the index of the next unused value from x1 + while True: # loop until we are finished + index_i: int = random.integers(0, i) # pick a random index-index + index: int = indices[index_i] # load the actual index + i = i - 1 # reduce the number of values + indices[i], indices[index_i] = index, indices[i] # swap + + if mode: # copy from x0 to dest + dest[index] = value = x0[index] # get and store value + for x1j in range(x1i, length): # mark as used + if (x1[x1j] == value) and (not x1_done[x1j]): + x1_done[x1j] = True # mark value as used + break # exit inner loop + if copy_from_x0 == i: # are we done with copying? + mode = False # set mode to load from x1 + x1i = 0 # reset x1 index + else: # go to next iteration + dest[index] = x1[x1i] # and store it in dest + if i == 0: # check if we are done + return # ok, we are finished + x1i = x1i + 1 # and move on to the next value + while x1_done[x1i]: # step x1i to next unused value + x1i = x1i + 1 # increment + + class Op2OrderBased(Op2): """The order-based crossover operator.""" @@ -64,49 +118,7 @@ def op2(self, random: Generator, dest: np.ndarray, :param x0: the first existing point in the search space :param x1: the second existing point in the search space """ - # end book - indices: Final[np.ndarray] = self.__indices - x1_done: Final[np.ndarray] = self.__x1_done - x1_done.fill(False) # all values in x1 are available - rint: Final[Callable[[int], int]] = random.integers - rbin: Final[Callable[[int, float], int]] = random.binomial - length: Final[int] = len(indices) # get length of string - copy_from_x0: int # the end index of copying from x0 - value: int # the current value to be written to dest - - # start book - while True: # sample the number of values to copy from x0 - copy_from_x0 = rbin(length, 0.5) # p=0.5 for each value - if 1 < copy_from_x0 < (length - 1): # ensure difference by - break # copying at least two values from each parent - copy_from_x0 = length - copy_from_x0 # compute end index-index - - i: int = length # the index into indices we iterate over - mode: bool = True # mode: True = copy from x0, False = from x1 - x1i: int = 0 # the index of the next unused value from x1 - while True: # loop until we are finished - index_i: int = rint(i) # pick a random index-index - index: int = indices[index_i] # load the actual index - i = i - 1 # reduce the number of values - indices[i], indices[index_i] = index, indices[i] # swap - - if mode: # copy from x0 to dest - dest[index] = value = x0[index] # get and store value - for x1j in range(x1i, length): # mark as used - if (x1[x1j] == value) and (not x1_done[x1j]): - x1_done[x1j] = True # mark value as used - break # exit inner loop - if copy_from_x0 == i: # are we done with copying? - mode = False # set mode to load from x1 - x1i = 0 # reset x1 index - else: # go to next iteration - dest[index] = x1[x1i] # and store it in dest - if i == 0: # check if we are done - return # ok, we are finished - x1i = x1i + 1 # and move on to the next value - while x1_done[x1i]: # step x1i to next unused value - x1i = x1i + 1 # increment - # end book + _op2_ox2(self.__indices, self.__x1_done, random, dest, x0, x1) def __init__(self, space: Permutations) -> None: """ diff --git a/moptipy/operators/signed_permutations/op1_swap_2_or_flip.py b/moptipy/operators/signed_permutations/op1_swap_2_or_flip.py index 4a1ab4315..b88ccbc3b 100644 --- a/moptipy/operators/signed_permutations/op1_swap_2_or_flip.py +++ b/moptipy/operators/signed_permutations/op1_swap_2_or_flip.py @@ -7,14 +7,14 @@ """ from typing import Callable, Final -# = import numba # type: ignore +import numba # type: ignore import numpy as np from numpy.random import Generator from moptipy.api.operators import Op1 -# Temporary fix for https://github.com/numba/numba/issues/9103 +@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) def swap_2_or_flip(random: Generator, dest: np.ndarray, x: np.ndarray) -> None: """ diff --git a/tests/operators/permutations/test_op1_insert1.py b/tests/operators/permutations/test_op1_insert1.py index bc875af14..f610535f2 100644 --- a/tests/operators/permutations/test_op1_insert1.py +++ b/tests/operators/permutations/test_op1_insert1.py @@ -1,13 +1,8 @@ """Test the unary insertion operation.""" -from typing import Callable - -import numpy as np -from numpy.random import default_rng # noinspection PyProtectedMember from moptipy.operators.permutations.op1_insert1 import ( Op1Insert1, - try_single_rotate, ) from moptipy.tests.on_permutations import validate_op1_on_permutations @@ -19,30 +14,3 @@ def _min_unique(samples, pwr) -> int: return max(1, min(samples, pwr.n()) // 2) validate_op1_on_permutations(Op1Insert1(), min_unique_samples=_min_unique) - - -def test_rotate() -> None: - """Test the rotation operator.""" - rnd: Callable[[int], int] = default_rng().integers - for i in range(1, 11): - x: np.ndarray = np.array([rnd(10) for _ in range(i)], int) - assert len(x) == i - - for j in range(i): - for k in range(i): - dst1: np.ndarray = x.copy() - dst2: np.ndarray = x.copy() - if j < k: - v = dst1[j] - dst1[j:k] = dst1[j + 1:k + 1] - dst1[k] = v - elif j > k: - v = dst1[j] - dst1[k + 1:j + 1] = dst1[k:j] - dst1[k] = v - - res = try_single_rotate(dst2, j, k) - assert res == all(dst1 == x) - if j == k: - assert res - assert all(dst2 == dst1)