Coverage for moptipy / operators / intspace / op1_mnormal.py: 60%

68 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-15 11:18 +0000

1""" 

2A multi-normal distribution. 

3 

4>>> from moptipy.utils.nputils import rand_generator 

5>>> gen = rand_generator(12) 

6 

7>>> from moptipy.operators.intspace.op0_random import Op0Random 

8>>> space = IntSpace(4, -2, 2) 

9>>> op0 = Op0Random(space) 

10>>> x1 = space.create() 

11>>> op0.op0(gen, x1) 

12>>> x1 

13array([-1, 2, 2, 1], dtype=int8) 

14 

15>>> op1 = Op1MNormal(space, 1, True, 1.5) 

16>>> op1.initialize() 

17>>> x2 = space.create() 

18>>> op1.op1(gen, x2, x1) 

19>>> x2 

20array([-1, 0, 0, 1], dtype=int8) 

21>>> op1.op1(gen, x2, x1) 

22>>> x2 

23array([-1, 2, 1, -2], dtype=int8) 

24>>> op1.op1(gen, x2, x1) 

25>>> x2 

26array([-1, 2, 2, 0], dtype=int8) 

27 

28>>> space = IntSpace(12, 0, 20) 

29>>> op0 = Op0Random(space) 

30>>> x1 = space.create() 

31>>> op0.op0(gen, x1) 

32>>> x1 

33array([ 6, 1, 18, 11, 13, 11, 2, 3, 13, 7, 10, 14], dtype=int8) 

34 

35>>> op1 = Op1MNormal(space, 1, True, 1.5) 

36>>> op1.initialize() 

37>>> x2 = space.create() 

38>>> op1.op1(gen, x2, x1) 

39>>> x2 

40array([ 6, 1, 18, 11, 13, 11, 2, 3, 12, 7, 9, 13], dtype=int8) 

41>>> op1.op1(gen, x2, x1) 

42>>> x2 

43array([ 6, 1, 20, 11, 13, 11, 2, 3, 13, 7, 10, 14], dtype=int8) 

44>>> op1.op1(gen, x2, x1) 

45>>> x2 

46array([ 3, 1, 18, 11, 13, 11, 2, 3, 13, 7, 9, 14], dtype=int8) 

47 

48>>> str(op1) 

49'normB1_1d5' 

50""" 

51from math import ceil, floor, isfinite 

52from typing import Final 

53 

54import numba # type: ignore 

55import numpy as np 

56from numpy.random import Generator 

57from pycommons.types import check_int_range, type_error 

58 

59from moptipy.api.operators import Op1 

60from moptipy.spaces.intspace import IntSpace 

61from moptipy.utils.logger import KeyValueLogSection 

62from moptipy.utils.nputils import ( 

63 fill_in_canonical_permutation, 

64 int_range_to_dtype, 

65) 

66from moptipy.utils.strings import num_to_str_for_name 

67 

68 

69@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) 

70def _mnormal(m: int, none_is_ok: bool, permutation: np.ndarray, 

71 random: Generator, sd: float, min_val: int, max_val: int, 

72 dest: np.ndarray, x: np.ndarray) -> None: 

73 """ 

74 Copy `x` into `dest` and sample normal distribution for each element n/m. 

75 

76 This method will first copy `x` to `dest`. Then it will decide for each 

77 value in `dest` whether it should be changed: The change happens with 

78 probability `m/n`, where `n` is the length of `dest`. 

79 Regardless of the probability, at least one element will always be 

80 changed if self.at_least_1 is True. 

81 

82 :param m: the value of m 

83 :param none_is_ok: is it OK to flip nothing? 

84 :param permutation: the internal permutation 

85 :param random: the random number generator 

86 :param sd: the standard deviation to be used for the normal distribution 

87 :param min_val: the minimal permissible value 

88 :param max_val: the maximal permissible value 

89 :param dest: the destination array to receive the new point 

90 :param x: the existing point in the search space 

91 """ 

92 dest[:] = x[:] # copy source to destination 

93 length: Final[int] = len(dest) # get n 

94 p: Final[float] = m / length # probability to change values 

95 

96 flips: int # the number of values to flip 

97 while True: 

98 flips = random.binomial(length, p) # get number of values to change 

99 if flips > 0: 

100 break # we will change some values 

101 if none_is_ok: 

102 return # we will change no values 

103 

104 i: int = length 

105 end: Final[int] = length - flips 

106 while i > end: # we iterate from i=length down to end=length-change 

107 k = random.integers(0, i) # index of next value index in permutation 

108 i -= 1 # decrease i 

109 idx = permutation[k] # get index of bit to value and move to end 

110 permutation[i], permutation[k] = idx, permutation[i] 

111 

112 # put a normal distribution around old value and sample 

113 # a new value 

114 old_value = dest[idx] 

115 while True: 

116 rnd = random.normal(scale=sd) 

117 new_value = old_value + (ceil(rnd) if rnd > 0 else floor(rnd)) 

118 if (new_value != old_value) and (min_val <= new_value <= max_val): 

119 break 

120 dest[idx] = new_value 

121 

122 

123class Op1MNormal(Op1): 

124 """Randomly choose a number of ints to change with normal distribution.""" 

125 

126 def __init__(self, space: IntSpace, m: int = 1, at_least_1: bool = True, 

127 sd: float = 1.0): 

128 """ 

129 Initialize the operator. 

130 

131 :param n: the length of the bit strings 

132 :param m: the factor for computing the probability of flipping 

133 the bits 

134 :param at_least_1: should at least one bit be flipped? 

135 """ 

136 super().__init__() 

137 if not isinstance(space, IntSpace): 

138 raise type_error(space, "space", IntSpace) 

139 #: the internal dimension 

140 self.__n: Final[int] = space.dimension 

141 #: the minimum permissible value 

142 self.__min: Final[int] = space.min_value 

143 #: the maximum permissible value 

144 self.__max: Final[int] = space.max_value 

145 #: the value of m in p=m/n 

146 self.__m: Final[int] = check_int_range(m, "m", 1, self.__n) 

147 if not isinstance(at_least_1, bool): 

148 raise type_error(at_least_1, "at_least_1", bool) 

149 #: is it OK to not flip any bit? 

150 self.__none_is_ok: Final[bool] = not at_least_1 

151 #: the internal permutation 

152 self.__permutation: Final[np.ndarray] = np.empty( 

153 self.__n, dtype=int_range_to_dtype(0, self.__n - 1)) 

154 if not isinstance(sd, float): 

155 raise type_error(sd, "sd", float) 

156 if not (isfinite(sd) and (0 < sd <= (self.__max - self.__min + 1))): 

157 raise ValueError( 

158 f"Invalid value {sd} for sd with {self.__min}..{self.__max}.") 

159 #: the internal standard deviation 

160 self.__sd: Final[float] = sd 

161 

162 def initialize(self) -> None: 

163 """Initialize this operator.""" 

164 super().initialize() 

165 fill_in_canonical_permutation(self.__permutation) 

166 

167 def op1(self, random: Generator, dest: np.ndarray, x: np.ndarray) -> None: 

168 """ 

169 Copy `x` into `dest` and change each value with probability m/n. 

170 

171 This method will first copy `x` to `dest`. Then it will change each 

172 value in `dest` with probability `m/n`, where `n` is the length of 

173 `dest`. Regardless of the probability, at least one value will always 

174 be changed if self.at_least_1 is True. 

175 

176 If a value is changed, we will put a normal distribution around it and 

177 sample it from that. Of course, we only accept values within the 

178 limits of the integer space and that are different from the original 

179 value. 

180 

181 :param self: the self pointer 

182 :param random: the random number generator 

183 :param dest: the destination array to receive the new point 

184 :param x: the existing point in the search space 

185 """ 

186 _mnormal(self.__m, self.__none_is_ok, self.__permutation, random, 

187 self.__sd, self.__min, self.__max, dest, x) 

188 

189 def __str__(self) -> str: 

190 """ 

191 Get the name of this unary operator. 

192 

193 :return: "fileB" + m + "n" if none-is-ok else "" 

194 """ 

195 return (f"normB{self.__m}{'n' if self.__none_is_ok else ''}_" 

196 f"{num_to_str_for_name(self.__sd)}") 

197 

198 def log_parameters_to(self, logger: KeyValueLogSection) -> None: 

199 """ 

200 Log the parameters of this operator to the given logger. 

201 

202 :param logger: the logger for the parameters 

203 """ 

204 super().log_parameters_to(logger) 

205 logger.key_value("m", self.__m) 

206 logger.key_value("n", self.__n) 

207 logger.key_value("sd", self.__sd) 

208 logger.key_value("min", self.__min) 

209 logger.key_value("max", self.__max)