Coverage for moptipy/api/operators.py: 83%

36 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-07 22:33 +0000

1""" 

2The base classes for implementing search operators. 

3 

4Nullary search operators are used to sample the initial starting points of the 

5optimization processes. They inherit from class 

6:class:`~moptipy.api.operators.Op0`. The pre-defined unit test routine 

7:func:`~moptipy.tests.op0.validate_op0` can and should be used to test all the 

8nullary operators that are implemented. 

9 

10Unary search operators accept one point in the search space as input and 

11generate a new, similar point as output. They inherit from class 

12:class:`~moptipy.api.operators.Op1`. The pre-defined unit test routine 

13:func:`~moptipy.tests.op1.validate_op1` can and should be used to test all the 

14unary operators that are implemented. 

15 

16The basic unary operators :class:`~moptipy.api.operators.Op1` have no 

17parameter telling them how much of the input point to change. They may do a 

18hard-coded number of modifications (as, e.g., 

19:class:`~moptipy.operators.permutations.op1_swap2.Op1Swap2` does) or may 

20apply a random number of modifications (like 

21:class:`~moptipy.operators.permutations.op1_swapn.Op1SwapN`). There is a 

22sub-class of unary operators named 

23:class:`~moptipy.api.operators.Op1WithStepSize` where a parameter `step_size` 

24with a value from the closed interval `[0.0, 1.0]` can be supplied. If 

25`step_size=0.0`, such an operator should perform the smallest possible 

26modification and for `step_size=1.0`, it should perform the largest possible 

27modification. 

28 

29Binary search operators accept two points in the search space as input and 

30generate a new point that should be similar to both inputs as output. They 

31inherit from class :class:`~moptipy.api.operators.Op2`. The pre-defined unit 

32test routine :func:`~moptipy.tests.op2.validate_op2` can and should be used to 

33test all the binary operators that are implemented. 

34""" 

35from typing import Any 

36from typing import Generator as ItGen 

37 

38from numpy.random import Generator 

39from pycommons.types import type_error 

40 

41from moptipy.api.component import Component 

42 

43 

44class Op0(Component): 

45 """A base class to implement a nullary search operator.""" 

46 

47 def op0(self, random: Generator, dest) -> None: 

48 """ 

49 Apply the nullary search operator to fill object `dest`. 

50 

51 Afterwards `dest` will hold a valid point in the search space. 

52 Often, this would be a point uniformly randomly sampled from the 

53 search space, but it could also be the result of a heuristic or 

54 even a specific solution. 

55 

56 :param random: the random number generator 

57 :param dest: the destination data structure 

58 """ 

59 raise ValueError("Method not implemented!") 

60 

61 

62def check_op0(op0: Any) -> Op0: 

63 """ 

64 Check whether an object is a valid instance of :class:`Op0`. 

65 

66 :param op0: the (supposed) instance of :class:`Op0` 

67 :return: the object `op0` 

68 :raises TypeError: if `op0` is not an instance of :class:`Op0` 

69 

70 >>> check_op0(Op0()) 

71 Op0 

72 >>> try: 

73 ... check_op0('A') 

74 ... except TypeError as te: 

75 ... print(te) 

76 op0 should be an instance of moptipy.api.operators.Op0 but is \ 

77str, namely 'A'. 

78 

79 >>> try: 

80 ... check_op0(None) 

81 ... except TypeError as te: 

82 ... print(te) 

83 op0 should be an instance of moptipy.api.operators.Op0 but is None. 

84 """ 

85 if isinstance(op0, Op0): 

86 return op0 

87 raise type_error(op0, "op0", Op0) 

88 

89 

90class Op1(Component): 

91 """A base class to implement a unary search operator.""" 

92 

93 def op1(self, random: Generator, dest, x) -> None: 

94 """ 

95 Fill `dest` with a modified copy of `x`. 

96 

97 :param random: the random number generator 

98 :param dest: the destination data structure 

99 :param x: the source point in the search space 

100 """ 

101 raise ValueError("Method not implemented!") 

102 

103 def iterate(self, random: Generator, dest, x) -> ItGen[Any, None, None]: 

104 """ 

105 Iterate possible moves. 

106 

107 This function returns a generator which iterates over a subset or the 

108 whole of the neighborhood spanned by :meth:`~op1`. 

109 In each step the parameter `dest` is modified. 

110 

111 The value yielded is implementation-specific. 

112 For some operator implementations, it may be the index of a decision 

113 variable that was changed. 

114 For other implementations, it may be `dest` itself. 

115 Don't rely on it unless you know what you are doing. 

116 

117 In ideal implementations, the order in which neighborhood is sampled 

118 should be randomized. 

119 This would allow you to draw a random sample of at most a pre-defined 

120 size from the neighborhood. 

121 

122 The default implementation here just applies :meth:`~op1` once and 

123 yields `dest`. 

124 

125 :param random: the random number generator 

126 :param dest: the destination data structure 

127 :param x: the source point in the search space 

128 """ 

129 self.op1(random, dest, x) 

130 yield dest 

131 

132 

133def check_op1(op1: Any) -> Op1: 

134 """ 

135 Check whether an object is a valid instance of :class:`Op1`. 

136 

137 :param op1: the (supposed) instance of :class:`Op1` 

138 :return: the object 

139 :raises TypeError: if `op1` is not an instance of :class:`Op1` 

140 

141 >>> check_op1(Op1()) 

142 Op1 

143 >>> try: 

144 ... check_op1('A') 

145 ... except TypeError as te: 

146 ... print(te) 

147 op1 should be an instance of moptipy.api.operators.Op1 but is str, \ 

148namely 'A'. 

149 

150 >>> try: 

151 ... check_op1(None) 

152 ... except TypeError as te: 

153 ... print(te) 

154 op1 should be an instance of moptipy.api.operators.Op1 but is None. 

155 """ 

156 if isinstance(op1, Op1): 

157 return op1 

158 raise type_error(op1, "op1", Op1) 

159 

160 

161class Op2(Component): 

162 """A base class to implement a binary search operator.""" 

163 

164 def op2(self, random: Generator, dest, x0, x1) -> None: 

165 """ 

166 Fill `dest` with a combination of `x0` and `x1`. 

167 

168 :param random: the random number generator 

169 :param dest: the destination data structure 

170 :param x0: the first source point in the search space 

171 :param x1: the second source point in the search space 

172 """ 

173 raise ValueError("Method not implemented!") 

174 

175 

176def check_op2(op2: Any) -> Op2: 

177 """ 

178 Check whether an object is a valid instance of :class:`Op2`. 

179 

180 :param op2: the (supposed) instance of :class:`Op2` 

181 :return: the object `op2` 

182 :raises TypeError: if `op2` is not an instance of :class:`Op2` 

183 

184 >>> check_op2(Op2()) 

185 Op2 

186 >>> try: 

187 ... check_op2('A') 

188 ... except TypeError as te: 

189 ... print(te) 

190 op2 should be an instance of moptipy.api.operators.Op2 but is str, \ 

191namely 'A'. 

192 

193 >>> try: 

194 ... check_op2(None) 

195 ... except TypeError as te: 

196 ... print(te) 

197 op2 should be an instance of moptipy.api.operators.Op2 but is None. 

198 """ 

199 if isinstance(op2, Op2): 

200 return op2 

201 raise type_error(op2, "op2", Op2) 

202 

203 

204class Op1WithStepSize(Op1): 

205 """A unary search operator with a step size.""" 

206 

207 def op1(self, random: Generator, dest, x, step_size: float = 0.0) -> None: 

208 """ 

209 Copy `x` to `dest` but apply a modification with a given `step_size`. 

210 

211 This operator is similar to :meth:`Op1.op1` in that it stores a 

212 modified copy of `x` into `dest`. The difference is that you can also 

213 specify how much that copy should be different: The parameter 

214 `step_size` can take on any value in the interval `[0.0, 1.0]`, 

215 including the two boundary values. A `step_size` of `0.0` indicates 

216 the smallest possible move (for which `dest` will still be different 

217 from `x`) and `step_size=1.0` will lead to the largest possible move. 

218 

219 The `step_size` may be interpreted differently by different operators: 

220 Some may interpret it as an exact requirement and enforce steps of the 

221 exact specified size, see, for example module 

222 :mod:`~moptipy.operators.bitstrings.op1_flip_m`. Others might 

223 interpret it stochastically as an expectation. Yet others may 

224 interpret it as a goal step width and try to realize it in a best 

225 effort kind of way, but may also do smaller or larger steps if the 

226 best effort fails, see for example module 

227 :mod:`~moptipy.operators.permutations.op1_swap_exactly_n`. 

228 What all operators should, however, have in common is that at 

229 `step_size=0.0`, they should try to perform a smallest possible change 

230 and at `step_size=1.0`, they should try to perform a largest possible 

231 change. For all values in between, step sizes should grow with rising 

232 `step_size`. This should allow algorithms that know nothing about the 

233 nature of the search space or the operator's moves to still tune 

234 between small and large moves based on a policy which makes sense in a 

235 black-box setting. 

236 

237 Every implementation of :class:`Op1WithStepSize` must specify a 

238 reasonable default value for this parameter ensure compatibility with 

239 :meth:`Op1.op1`. In this base class, we set the default to `0.0`. 

240 

241 Finally, if a `step_size` value is passed in which is outside the 

242 interval `[0, 1]`, the behavior of this method is undefined. It may 

243 throw an exception or not. It may also enter an infinite loop. 

244 

245 :param random: the random number generator 

246 :param dest: the destination data structure 

247 :param x: the source point in the search space 

248 :param step_size: the step size parameter for the unary operator 

249 """ 

250 raise ValueError("Method not implemented!") 

251 

252 

253def check_op1_with_step_size(op1: Any) -> Op1WithStepSize: 

254 """ 

255 Check whether an object is a valid instance of :class:`Op1WithStepSize`. 

256 

257 :param op1: the (supposed) instance of :class:`Op1WithStepSize` 

258 :return: the object `op1` 

259 :raises TypeError: if `op1` is not an instance of :class:`Op1WithStepSize` 

260 

261 >>> check_op1_with_step_size(Op1WithStepSize()) 

262 Op1WithStepSize 

263 >>> try: 

264 ... check_op1_with_step_size('A') 

265 ... except TypeError as te: 

266 ... print(te) 

267 op1 should be an instance of moptipy.api.operators.Op1WithStepSize \ 

268but is str, namely 'A'. 

269 

270 >>> try: 

271 ... check_op1_with_step_size(Op1()) 

272 ... except TypeError as te: 

273 ... print(te) 

274 op1 should be an instance of moptipy.api.operators.Op1WithStepSize \ 

275but is moptipy.api.operators.Op1. 

276 

277 >>> try: 

278 ... check_op1_with_step_size(None) 

279 ... except TypeError as te: 

280 ... print(te) 

281 op1 should be an instance of moptipy.api.operators.Op1WithStepSize \ 

282but is None. 

283 """ 

284 if isinstance(op1, Op1WithStepSize): 

285 return op1 

286 raise type_error(op1, "op1", Op1WithStepSize)