Coverage for moptipy / api / process.py: 86%

57 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-24 08:49 +0000

1""" 

2Processes offer data to both the user and the optimization algorithm. 

3 

4They provide the information about the optimization process and its current 

5state as handed to the optimization algorithm and, after the algorithm has 

6finished, to the user. They also supply the optimization algorithm with 

7everything it needs to run, e.g., random numbers 

8(:meth:`~moptipy.api.process.Process.get_random`), they evaluate solutions 

9(:meth:`~moptipy.api.process.Process.evaluate`) , and they tell it when to 

10stop (:meth:`~moptipy.api.process.Process.should_terminate`). 

11 

12The idea behind this interface is to treat optimization algorithms as 

13so-called *anytime algorithms*. An anytime algorithm will begin with a guess 

14about what the solution for a problem could be. It will then iteratively 

15sample and evaluate (:meth:`~moptipy.api.process.Process.evaluate`) new 

16solutions, i.e., new and hopefully better guesses. It can be stopped at any 

17time, e.g., by the termination criterion, 

18:meth:`~moptipy.api.process.Process.should_terminate` and then return the best 

19guess of the solution (:meth:`~moptipy.api.process.Process.get_copy_of_best_y`, 

20:meth:`~moptipy.api.process.Process.get_best_f`). 

21 

22The process API also collects all the information about the optimization 

23process, performs in-memory logging if wanted, and can write a standardized, 

24text-based log file for each run of an algorithm in a clear folder structure. 

25By storing information about the algorithm, the problem, and the system, as 

26well as the random seed, this allows for self-documenting and replicable 

27experiments. 

28 

29The class :class:`Process` is a base class from which all optimization 

30processes are derived. It is for the standard single-objective optimization 

31case. A multi-objective variant is given in module 

32:mod:`~moptipy.api.mo_process` as class 

33:class:`~moptipy.api.mo_process.MOProcess`. 

34 

35Furthermore, processes also lent themselves to "forking" off some of the 

36computational budget of an algorithm to sub-algorithms. For this purpose, the 

37module :mod:`~moptipy.api.subprocesses` provides specialized routines, such as 

38:func:`~moptipy.api.subprocesses.for_fes` for creating sub-processes that 

39forward all method calls to the original process but will perform at most a 

40given number of objective function evaluations or 

41:func:`~moptipy.api.subprocesses.from_starting_point`, which creates a 

42sub-process that has the current-best solution pre-set to a given point in the 

43search space and its quality. 

44:func:`~moptipy.api.subprocesses.without_should_terminate` wraps a process in 

45such a way that the termination criterion 

46:meth:`~moptipy.api.process.Process.should_terminate`, which is suitable for 

47invoking externally implemented optimization algorithms that do not know/care 

48about the `moptipy` API. 

49 

501. Mark S. Boddy and Thomas L. Dean. *Solving Time-Dependent Planning 

51 Problems.* Report CS-89-03, February 1989. Providence, RI, USA: Brown 

52 University, Department of Computer Science. 

53 ftp://ftp.cs.brown.edu/pub/techreports/89/cs89-03.pdf 

54""" 

55from contextlib import AbstractContextManager 

56from math import inf, isnan 

57 

58from numpy.random import Generator 

59from pycommons.types import check_int_range, type_error 

60 

61from moptipy.api.objective import Objective 

62from moptipy.api.space import Space 

63 

64 

65# start book 

66class Process(Space, Objective, AbstractContextManager): 

67 """ 

68 Processes offer data to the optimization algorithm and the user. 

69 

70 A Process presents the objective function and search space to an 

71 optimization algorithm. Since it wraps the actual objective 

72 function, it can see all evaluated solutions and remember the 

73 best-so-far solution. It can also count the FEs and the runtime 

74 that has passed. Therefore, it also presents the termination 

75 criterion to the optimization algorithm. It also provides a random 

76 number generator the algorithm. It can write log files with the 

77 progress of the search and the end result. Finally, it provides 

78 the end result to the user, who can access it after the algorithm 

79 has finished. 

80 """ 

81 

82# end book 

83 

84 def get_random(self) -> Generator: # +book 

85 """ 

86 Obtain the random number generator. 

87 

88 The optimization algorithm and all of its components must only use 

89 this random number generator for all their non-deterministic 

90 decisions. In order to guarantee reproducible runs, there must not be 

91 any other source of randomness. This generator can be seeded in the 

92 :meth:`~moptipy.api.execution.Execution.set_rand_seed` method of the 

93 :class:`~moptipy.api.execution.Execution` builder object. 

94 

95 :return: the random number generator 

96 """ 

97 

98 def should_terminate(self) -> bool: # +book 

99 """ 

100 Check whether the optimization process should terminate. 

101 

102 If this function returns `True`, the optimization process must 

103 not perform any objective function evaluations anymore. 

104 It will automatically become `True` when a termination criterion 

105 is hit or if anyone calls :meth:`terminate`, which happens also 

106 at the end of a `with` statement. 

107 

108 Generally, the termination criterion is configured by the methods 

109 :meth:`~moptipy.api.execution.Execution.set_max_fes`, 

110 :meth:`~moptipy.api.execution.Execution.set_max_time_millis`, and 

111 :meth:`~moptipy.api.execution.Execution.set_goal_f` of the 

112 :class:`~moptipy.api.execution.Execution` builder. Furthermore, if 

113 the objective function has a finite 

114 :meth:`~moptipy.api.objective.Objective.lower_bound`, then this lower 

115 bound is also used as goal objective value if no goal objective value 

116 is specified via :meth:`~moptipy.api.execution.Execution.set_goal_f`. 

117 :meth:`should_terminate` then returns `True` as soon as any one of the 

118 configured criteria is met, i.e., the process terminates when the 

119 earliest one of the criteria is met. 

120 

121 :return: `True` if the process should terminate, `False` if not 

122 """ 

123 

124 def evaluate(self, x) -> float | int: # +book 

125 """ 

126 Evaluate a solution `x` and return its objective value. 

127 

128 This method implements the 

129 :meth:`~moptipy.api.objective.Objective.evaluate` method of 

130 the :class:`moptipy.api.objective.Objective` function interface, 

131 but on :class:`Process` level. 

132 

133 The return value is either an integer or a float and must be 

134 finite. Smaller objective values are better, i.e., all objective 

135 functions are subject to minimization. 

136 

137 This method here is usually a wrapper that internally invokes the 

138 actual :class:`~moptipy.api.objective.Objective` function, but it does 

139 more: While it does use the 

140 :meth:`~moptipy.api.objective.Objective.evaluate` method of the 

141 objective function to compute the quality of a candidate solution, 

142 it also internally increments the counter for the objective function 

143 evaluations (FEs) that have passed. You can request the number of 

144 these FEs via :meth:`get_consumed_fes` (and also the time that has 

145 passed via :meth:`get_consumed_time_millis`, but this is unrelated 

146 to the :meth:`evaluate` method). 

147 

148 Still, counting the FEs like this allows us to know when, e.g., the 

149 computational budget in terms of a maximum permitted number of FEs 

150 has been exhausted, in which case :meth:`should_terminate` will 

151 become `True`. 

152 

153 Also, since this method will see all objective values and the 

154 corresponding candidate solutions, it is able to internally remember 

155 the best solution you have ever created and its corresponding 

156 objective value. Therefore, the optimization :class:`Process` can 

157 provide both to you via the methods :meth:`has_best`, 

158 :meth:`get_copy_of_best_x`, :meth:`get_copy_of_best_y`, and 

159 :meth:`get_best_f`. At the same time, if a goal objective value or 

160 lower bound for the objective function is specified and one solution 

161 is seen that has such a quality, :meth:`should_terminate` will again 

162 become `True`. 

163 

164 Finally, this method also performs all logging, e.g., of improving 

165 moves, in memory if logging is activated. (See 

166 :meth:`~moptipy.api.execution.Execution.set_log_file`, 

167 :meth:`~moptipy.api.execution.Execution.set_log_improvements`, and 

168 :meth:`~moptipy.api.execution.Execution.set_log_all_fes`.) 

169 

170 In some cases, you may not need to invoke the original objective 

171 function via this wrapper to obtain the objective value of a solution. 

172 Indeed, in some cases you *know* the objective value because of the 

173 way you constructed the solution. However, you still need to tell our 

174 system the objective value and provide the solution to ensure the 

175 correct counting of FEs, the correct preservation of the best 

176 solution, and the correct setting of the termination criterion. For 

177 these situations, you will call :meth:`register` instead of 

178 :meth:`evaluate`. 

179 

180 :param x: the candidate solution 

181 :return: the objective value 

182 """ 

183 

184 def register(self, x, f: int | float) -> None: 

185 """ 

186 Register a solution `x` with externally-evaluated objective value. 

187 

188 This function is equivalent to :meth:`evaluate`, but receives the 

189 objective value as parameter. In some problems, algorithms can compute 

190 the objective value of a solution very efficiently without passing it 

191 to the objective function. 

192 

193 For example, on the Traveling Salesperson Problem with n cities, if 

194 you have a tour of known length and swap two cities in it, then you 

195 can compute the overall tour length in O(1) instead of O(n) that you 

196 would need to pay for a full evaluation. In such a case, you could 

197 use `register` instead of `evaluate`. 

198 

199 `x` must be provided if `f` marks an improvement. In this case, the 

200 contents of `x` will be copied to an internal variable remembering the 

201 best-so-far solution. If `f` is not an improvement, you may pass in 

202 `None` for `x` or just any valid point in the search space. 

203 

204 For each candidate solution you construct, you must call either 

205 :meth:`evaluate` or :meth:`register`. This is because these two 

206 functions also count the objective function evaluations (FEs) that 

207 have passed. This is needed to check the termination criterion, for 

208 instance. 

209 

210 :param x: the candidate solution 

211 :param f: the objective value 

212 """ 

213 

214 def get_consumed_fes(self) -> int: 

215 """ 

216 Obtain the number consumed objective function evaluations. 

217 

218 This is the number of calls to :meth:`evaluate`. 

219 

220 :return: the number of objective function evaluations so far 

221 """ 

222 

223 def get_consumed_time_millis(self) -> int: 

224 """ 

225 Obtain an approximation of the consumed runtime in milliseconds. 

226 

227 :return: the consumed runtime measured in milliseconds. 

228 :rtype: int 

229 """ 

230 

231 def get_max_fes(self) -> int | None: 

232 """ 

233 Obtain the maximum number of permitted objective function evaluations. 

234 

235 If no limit is set, `None` is returned. 

236 

237 :return: the maximum number of objective function evaluations, 

238 or `None` if no limit is specified. 

239 """ 

240 

241 def get_max_time_millis(self) -> int | None: 

242 """ 

243 Obtain the maximum runtime permitted in milliseconds. 

244 

245 If no limit is set, `None` is returned. 

246 

247 :return: the maximum runtime permitted in milliseconds, 

248 or `None` if no limit is specified. 

249 """ 

250 

251 def has_best(self) -> bool: # +book 

252 """ 

253 Check whether a current best solution is available. 

254 

255 As soon as one objective function evaluation has been performed, 

256 the black-box process can provide a best-so-far solution. Then, 

257 this method returns `True`. Otherwise, it returns `False`. This 

258 means that this method returns `True` if and only if you have 

259 called either :meth:`evaluate` or :meth:`register` at least once. 

260 

261 :return: True if the current-best solution can be queried. 

262 

263 See Also 

264 - :meth:`get_best_f` 

265 - :meth:`get_copy_of_best_x` 

266 - :meth:`get_copy_of_best_y` 

267 """ 

268 

269 def get_best_f(self) -> int | float: # +book 

270 """ 

271 Get the objective value of the current best solution. 

272 

273 This always corresponds to the best-so-far solution, i.e., the 

274 best solution that you have passed to :meth:`evaluate` or 

275 :meth:`register` so far. It is *NOT* the best possible objective 

276 value for the optimization problem. It is the best objective value 

277 that the process has seen *so far*, the current best objective value. 

278 

279 You should only call this method if you are either sure that you 

280 have invoked meth:`evaluate` before :meth:`register` of if you called 

281 :meth:`has_best` before and it returned `True`. 

282 

283 :return: the objective value of the current best solution. 

284 

285 See Also 

286 - :meth:`has_best` 

287 - :meth:`get_copy_of_best_x` 

288 - :meth:`get_copy_of_best_y` 

289 """ 

290 

291 def get_copy_of_best_x(self, x) -> None: # +book 

292 """ 

293 Get a copy of the current best point in the search space. 

294 

295 This always corresponds to the point in the search space encoding the 

296 best-so-far solution, i.e., the best point in the search space that 

297 you have passed to :meth:`evaluate` or :meth:`register` so far. 

298 It is *NOT* the best global optimum for the optimization problem. It 

299 corresponds to the best solution that the process has seen *so far*, 

300 the current best solution. 

301 

302 Even if the optimization algorithm using this process does not 

303 preserve this solution in special variable and has already lost it 

304 again, this method will still return it. The optimization process 

305 encapsulated by this `process` object will always remember it. 

306 

307 This also means that your algorithm implementations do not need to 

308 store the best-so-far solution anywhere if doing so would be 

309 complicated. They can obtain it simply from this method whenever 

310 needed. 

311 

312 You should only call this method if you are either sure that you 

313 have invoked :meth:`evaluate` before :meth:`register` of if you called 

314 :meth:`has_best` before and it returned `True`. 

315 

316 For understanding the relationship between the search space and the 

317 solution space, see module :mod:`~moptipy.api.encoding`. 

318 

319 :param x: the destination data structure to be overwritten 

320 

321 See Also 

322 - :meth:`has_best` 

323 - :meth:`get_best_f` 

324 - :meth:`get_copy_of_best_y` 

325 """ 

326 

327 def get_copy_of_best_y(self, y) -> None: # +book 

328 """ 

329 Get a copy of the current best point in the solution space. 

330 

331 This always corresponds to the best-so-far solution, i.e., the 

332 best solution that you have passed to :meth:`evaluate` or 

333 :meth:`register` so far. It is *NOT* the global optimum for the 

334 optimization problem. It is the best solution that the process has 

335 seen *so far*, the current best solution. 

336 

337 You should only call this method if you are either sure that you 

338 have invoked meth:`evaluate` before :meth:`register` of if you called 

339 :meth:`has_best` before and it returned `True`. 

340 

341 :param y: the destination data structure to be overwritten 

342 

343 See Also 

344 - :meth:`has_best` 

345 - :meth:`get_best_f` 

346 - :meth:`get_copy_of_best_x` 

347 """ 

348 

349 def get_last_improvement_fe(self) -> int: # +book 

350 """ 

351 Get the FE at which the last improvement was made. 

352 

353 You should only call this method if you are either sure that you 

354 have invoked meth:`evaluate` before :meth:`register` of if you called 

355 :meth:`has_best` before and it returned `True`. 

356 

357 :return: the function evaluation when the last improvement was made 

358 :raises ValueError: if no FE was performed yet 

359 """ 

360 

361 def get_last_improvement_time_millis(self) -> int: 

362 """ 

363 Get the FE at which the last improvement was made. 

364 

365 You should only call this method if you are either sure that you 

366 have invoked meth:`evaluate` before :meth:`register` of if you called 

367 :meth:`has_best` before and it returned `True`. 

368 

369 :return: the function evaluation when the last improvement was made 

370 :raises ValueError: if no FE was performed yet 

371 """ 

372 

373 def __str__(self) -> str: 

374 """ 

375 Get the name of this process implementation. 

376 

377 This method is overwritten for each subclass of :class:`Process` 

378 and then returns a short descriptive value of these classes. 

379 

380 :return: "process" for this base class 

381 """ 

382 return "process" 

383 

384 def terminate(self) -> None: # +book 

385 """ 

386 Terminate this process. 

387 

388 This function is automatically called at the end of the `with` 

389 statement, but can also be called by the algorithm when it is 

390 finished and is also invoked automatically when a termination 

391 criterion is hit. 

392 After the first time this method is invoked, :meth:`should_terminate` 

393 becomes `True`. 

394 """ 

395 

396 def has_log(self) -> bool: 

397 """ 

398 Will any information of this process be logged?. 

399 

400 Only if this method returns `True`, invoking :meth:`add_log_section` 

401 makes any sense. Otherwise, the data would just be discarded. 

402 

403 :retval `True`: if the process is associated with a log output 

404 :retval `False`: if no information is stored in a log output 

405 """ 

406 

407 def add_log_section(self, title: str, text: str) -> None: 

408 """ 

409 Add a section to the log, if a log is written (otherwise ignore it). 

410 

411 When creating the experiment 

412 :class:`~moptipy.api.execution.Execution`, you can specify a log file 

413 via method :meth:`~moptipy.api.execution.Execution.set_log_file`. 

414 Then, the results of your algorithm and the system configuration will 

415 be stored as text in this file. Each type of information will be 

416 stored in a different section. The end state with the final solution 

417 quality, for instance, will be stored in a section named `STATE`. 

418 Each section begins with the line `BEGIN_XXX` and ends with the line 

419 `END_XXX`, where `XXX` is the name of the section. Between these two 

420 lines, all the contents of the section are stored. 

421 

422 This method here allows you to add a custom section to your log file. 

423 This can happen in your implementation of the method 

424 :meth:`~moptipy.api.algorithm.Algorithm.solve` of your algorithm. 

425 (Ideally at its end.) Of course, invoking this method only makes sense 

426 if there actually is a log file. You can check for this by calling 

427 :meth:`has_log`. 

428 

429 You can specify a custom section name (which must be in upper case 

430 characters) and a custom section body text. 

431 Of course, the name of this section must not clash with any other 

432 section name. Neither the section name nor section body should contain 

433 strings like `BEGIN_` or `END_`, and such and such. You do not want to 

434 mess up your log files. Ofcourse you can add a section with a given 

435 name only once, because otherwise there would be a name clash. 

436 Anyway, if you add sections like this, they will be appended at the 

437 end of the log file. This way, you have all the standard log data and 

438 your additional information in one consistent file. 

439 

440 Be advised: Adding sections costs time and memory. You do not want to 

441 do such a thing in a loop. If your algorithm should store additional 

442 data, it makes sense to gather this data in an efficient way during 

443 the run and only flush it to a section at the end of the run. 

444 

445 :param title: the title of the log section 

446 :param text: the text to log 

447 """ 

448 

449 def get_log_basename(self) -> str | None: 

450 """ 

451 Get the basename of the log, if any. 

452 

453 If a log file is associated with this process, then this function 

454 returns the name of the log file without the file suffix. If no log 

455 file is associated with the process, then `None` is returned. 

456 

457 This can be used to store additional information during the run of 

458 the optimization algorithm. However, treat this carefully, as some 

459 files with the same base name may exist or be generated by other 

460 modules. 

461 

462 :returns: the path to the log file without the file suffix if a log 

463 file is associated with the process, or `None` otherwise 

464 """ 

465 return None 

466 

467 def initialize(self) -> None: 

468 """ 

469 Raise an error because this method shall never be called. 

470 

471 :raises ValueError: always 

472 """ 

473 raise ValueError("Never call the initialize() method of a Process!") 

474 

475 def __enter__(self) -> "Process": 

476 """ 

477 Begin a `with` statement. 

478 

479 :return: this process itself 

480 """ 

481 return self 

482 

483 def __exit__(self, exception_type, exception_value, traceback) -> bool: 

484 """ 

485 End a `with` statement. 

486 

487 :param exception_type: ignored 

488 :param exception_value: ignored 

489 :param traceback: ignored 

490 :returns: `True` to suppress an exception, `False` to rethrow it 

491 """ 

492 self.terminate() 

493 return exception_type is None 

494 

495 

496def check_max_fes(max_fes: int | None, 

497 none_is_ok: bool = False) -> int | None: 

498 """ 

499 Check the maximum FEs. 

500 

501 This is a small utility method that validates whether a maximum for the 

502 objective function evaluations (FEs) is valid. 

503 

504 :param max_fes: the maximum FEs 

505 :param none_is_ok: is `None` ok? 

506 :return: the maximum fes, or `None` 

507 :raises TypeError: if `max_fes` is `None` (and `None` is not allowed) or 

508 not an `int` 

509 :raises ValueError: if `max_fes` is invalid 

510 """ 

511 if max_fes is None: 

512 if none_is_ok: 

513 return None 

514 raise type_error(max_fes, "max_fes", int) 

515 return check_int_range(max_fes, "max_fes", 1, 1_000_000_000_000_000) 

516 

517 

518def check_max_time_millis(max_time_millis: int | None, 

519 none_is_ok: bool = False) -> int | None: 

520 """ 

521 Check the maximum time in milliseconds. 

522 

523 This is a small utility method that validates whether a maximum for the 

524 milliseconds that can be used as runtime limit is valid. 

525 

526 :param max_time_millis: the maximum time in milliseconds 

527 :param none_is_ok: is None ok? 

528 :return: the maximum time in milliseconds, or `None` 

529 :raises TypeError: if `max_time_millis` is `None` (and `None` is not 

530 allowed) or not an `int` 

531 :raises ValueError: if `max_time_millis` is invalid 

532 """ 

533 if max_time_millis is None: 

534 if none_is_ok: 

535 return None 

536 raise type_error(max_time_millis, "max_time_millis", int) 

537 return check_int_range( 

538 max_time_millis, "max_time_millis", 1, 100_000_000_000) 

539 

540 

541def check_goal_f(goal_f: int | float | None, 

542 none_is_ok: bool = False) -> int | float | None: 

543 """ 

544 Check the goal objective value. 

545 

546 This is a small utility method that validates whether a goal objective 

547 value is valid. 

548 

549 :param goal_f: the goal objective value 

550 :param none_is_ok: is `None` ok? 

551 :return: the goal objective value, or `None` 

552 :raises TypeError: if `goal_f` is `None` (and `None` is not allowed) or 

553 neither an `int` nor a `float` 

554 :raises ValueError: if `goal_f` is invalid 

555 """ 

556 if not (isinstance(goal_f, int | float)): 

557 if none_is_ok and (goal_f is None): 

558 return None 

559 raise type_error(goal_f, "goal_f", (int, float)) 

560 if isnan(goal_f): 

561 raise ValueError("Goal objective value must not be NaN, but is " 

562 f"{goal_f}.") 

563 if goal_f >= inf: 

564 raise ValueError("Goal objective value must be less than positive " 

565 f"infinity, but is {goal_f}.") 

566 return goal_f