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

58 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-01-28 04:20 +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 

57from typing import Self 

58 

59from numpy.random import Generator 

60from pycommons.types import check_int_range, type_error 

61 

62from moptipy.api.objective import Objective 

63from moptipy.api.space import Space 

64 

65 

66# start book 

67class Process(Space, Objective, AbstractContextManager): 

68 """ 

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

70 

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

72 optimization algorithm. Since it wraps the actual objective 

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

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

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

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

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

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

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

80 has finished. 

81 """ 

82 

83# end book 

84 

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

86 """ 

87 Obtain the random number generator. 

88 

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

90 this random number generator for all their non-deterministic 

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

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

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

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

95 

96 :return: the random number generator 

97 """ 

98 

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

100 """ 

101 Check whether the optimization process should terminate. 

102 

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

104 not perform any objective function evaluations anymore. 

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

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

107 at the end of a `with` statement. 

108 

109 Generally, the termination criterion is configured by the methods 

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

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

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

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

114 the objective function has a finite 

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

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

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

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

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

120 earliest one of the criteria is met. 

121 

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

123 """ 

124 

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

126 """ 

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

128 

129 This method implements the 

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

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

132 but on :class:`Process` level. 

133 

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

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

136 functions are subject to minimization. 

137 

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

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

140 more: While it does use the 

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

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

143 it also internally increments the counter for the objective function 

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

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

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

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

148 

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

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

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

152 become `True`. 

153 

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

155 corresponding candidate solutions, it is able to internally remember 

156 the best solution you have ever created and its corresponding 

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

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

159 :meth:`get_copy_of_best_x`, :meth:`get_copy_of_best_y`, and 

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

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

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

163 become `True`. 

164 

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

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

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

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

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

170 

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

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

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

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

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

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

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

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

179 :meth:`evaluate`. 

180 

181 :param x: the candidate solution 

182 :return: the objective value 

183 """ 

184 

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

186 """ 

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

188 

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

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

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

192 to the objective function. 

193 

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

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

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

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

198 use `register` instead of `evaluate`. 

199 

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

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

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

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

204 

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

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

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

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

209 instance. 

210 

211 :param x: the candidate solution 

212 :param f: the objective value 

213 """ 

214 

215 def get_consumed_fes(self) -> int: 

216 """ 

217 Obtain the number consumed objective function evaluations. 

218 

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

220 

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

222 """ 

223 

224 def get_consumed_time_millis(self) -> int: 

225 """ 

226 Obtain an approximation of the consumed runtime in milliseconds. 

227 

228 :return: the consumed runtime measured in milliseconds. 

229 :rtype: int 

230 """ 

231 

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

233 """ 

234 Obtain the maximum number of permitted objective function evaluations. 

235 

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

237 

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

239 or `None` if no limit is specified. 

240 """ 

241 

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

243 """ 

244 Obtain the maximum runtime permitted in milliseconds. 

245 

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

247 

248 :return: the maximum runtime permitted in milliseconds, 

249 or `None` if no limit is specified. 

250 """ 

251 

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

253 """ 

254 Check whether a current best solution is available. 

255 

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

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

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

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

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

261 

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

263 

264 See Also 

265 - :meth:`get_best_f` 

266 - :meth:`get_copy_of_best_x` 

267 - :meth:`get_copy_of_best_y` 

268 """ 

269 

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

271 """ 

272 Get the objective value of the current best solution. 

273 

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

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

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

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

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

279 

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

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

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

283 

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

285 

286 See Also 

287 - :meth:`has_best` 

288 - :meth:`get_copy_of_best_x` 

289 - :meth:`get_copy_of_best_y` 

290 """ 

291 

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

293 """ 

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

295 

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

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

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

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

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

301 the current best solution. 

302 

303 Even if the optimization algorithm using this process does not 

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

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

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

307 

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

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

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

311 needed. 

312 

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

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

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

316 

317 For understanding the relationship between the search space and the 

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

319 

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

321 

322 See Also 

323 - :meth:`has_best` 

324 - :meth:`get_best_f` 

325 - :meth:`get_copy_of_best_y` 

326 """ 

327 

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

329 """ 

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

331 

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

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

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

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

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

337 

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

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

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

341 

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

343 

344 See Also 

345 - :meth:`has_best` 

346 - :meth:`get_best_f` 

347 - :meth:`get_copy_of_best_x` 

348 """ 

349 

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

351 """ 

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

353 

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

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

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

357 

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

359 :raises ValueError: if no FE was performed yet 

360 """ 

361 

362 def get_last_improvement_time_millis(self) -> int: 

363 """ 

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

365 

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

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

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

369 

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

371 :raises ValueError: if no FE was performed yet 

372 """ 

373 

374 def __str__(self) -> str: 

375 """ 

376 Get the name of this process implementation. 

377 

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

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

380 

381 :return: "process" for this base class 

382 """ 

383 return "process" 

384 

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

386 """ 

387 Terminate this process. 

388 

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

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

391 finished and is also invoked automatically when a termination 

392 criterion is hit. 

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

394 becomes `True`. 

395 """ 

396 

397 def has_log(self) -> bool: 

398 """ 

399 Will any information of this process be logged?. 

400 

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

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

403 

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

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

406 """ 

407 

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

409 """ 

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

411 

412 When creating the experiment 

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

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

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

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

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

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

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

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

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

422 

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

424 This can happen in your implementation of the method 

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

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

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

428 :meth:`has_log`. 

429 

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

431 characters) and a custom section body text. 

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

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

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

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

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

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

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

439 your additional information in one consistent file. 

440 

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

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

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

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

445 

446 :param title: the title of the log section 

447 :param text: the text to log 

448 """ 

449 

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

451 """ 

452 Get the basename of the log, if any. 

453 

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

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

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

457 

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

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

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

461 modules. 

462 

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

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

465 """ 

466 return None 

467 

468 def initialize(self) -> None: 

469 """ 

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

471 

472 :raises ValueError: always 

473 """ 

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

475 

476 def __enter__(self) -> Self: 

477 """ 

478 Begin a `with` statement. 

479 

480 :return: this process itself 

481 """ 

482 return self 

483 

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

485 """ 

486 End a `with` statement. 

487 

488 :param exception_type: ignored 

489 :param exception_value: ignored 

490 :param traceback: ignored 

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

492 """ 

493 self.terminate() 

494 return exception_type is None 

495 

496 

497def check_max_fes(max_fes: int | None, 

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

499 """ 

500 Check the maximum FEs. 

501 

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

503 objective function evaluations (FEs) is valid. 

504 

505 :param max_fes: the maximum FEs 

506 :param none_is_ok: is `None` ok? 

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

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

509 not an `int` 

510 :raises ValueError: if `max_fes` is invalid 

511 """ 

512 if max_fes is None: 

513 if none_is_ok: 

514 return None 

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

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

517 

518 

519def check_max_time_millis(max_time_millis: int | None, 

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

521 """ 

522 Check the maximum time in milliseconds. 

523 

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

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

526 

527 :param max_time_millis: the maximum time in milliseconds 

528 :param none_is_ok: is None ok? 

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

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

531 allowed) or not an `int` 

532 :raises ValueError: if `max_time_millis` is invalid 

533 """ 

534 if max_time_millis is None: 

535 if none_is_ok: 

536 return None 

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

538 return check_int_range( 

539 max_time_millis, "max_time_millis", 1, 100_000_000_000) 

540 

541 

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

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

544 """ 

545 Check the goal objective value. 

546 

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

548 value is valid. 

549 

550 :param goal_f: the goal objective value 

551 :param none_is_ok: is `None` ok? 

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

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

554 neither an `int` nor a `float` 

555 :raises ValueError: if `goal_f` is invalid 

556 """ 

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

558 if none_is_ok and (goal_f is None): 

559 return None 

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

561 if isnan(goal_f): 

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

563 f"{goal_f}.") 

564 if goal_f >= inf: 

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

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

567 return goal_f