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
« 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.
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`).
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`).
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.
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`.
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.
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
59from numpy.random import Generator
60from pycommons.types import check_int_range, type_error
62from moptipy.api.objective import Objective
63from moptipy.api.space import Space
66# start book
67class Process(Space, Objective, AbstractContextManager):
68 """
69 Processes offer data to the optimization algorithm and the user.
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 """
83# end book
85 def get_random(self) -> Generator: # +book
86 """
87 Obtain the random number generator.
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.
96 :return: the random number generator
97 """
99 def should_terminate(self) -> bool: # +book
100 """
101 Check whether the optimization process should terminate.
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.
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.
122 :return: `True` if the process should terminate, `False` if not
123 """
125 def evaluate(self, x) -> float | int: # +book
126 """
127 Evaluate a solution `x` and return its objective value.
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.
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.
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).
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`.
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`.
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`.)
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`.
181 :param x: the candidate solution
182 :return: the objective value
183 """
185 def register(self, x, f: int | float) -> None:
186 """
187 Register a solution `x` with externally-evaluated objective value.
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.
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`.
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.
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.
211 :param x: the candidate solution
212 :param f: the objective value
213 """
215 def get_consumed_fes(self) -> int:
216 """
217 Obtain the number consumed objective function evaluations.
219 This is the number of calls to :meth:`evaluate`.
221 :return: the number of objective function evaluations so far
222 """
224 def get_consumed_time_millis(self) -> int:
225 """
226 Obtain an approximation of the consumed runtime in milliseconds.
228 :return: the consumed runtime measured in milliseconds.
229 :rtype: int
230 """
232 def get_max_fes(self) -> int | None:
233 """
234 Obtain the maximum number of permitted objective function evaluations.
236 If no limit is set, `None` is returned.
238 :return: the maximum number of objective function evaluations,
239 or `None` if no limit is specified.
240 """
242 def get_max_time_millis(self) -> int | None:
243 """
244 Obtain the maximum runtime permitted in milliseconds.
246 If no limit is set, `None` is returned.
248 :return: the maximum runtime permitted in milliseconds,
249 or `None` if no limit is specified.
250 """
252 def has_best(self) -> bool: # +book
253 """
254 Check whether a current best solution is available.
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.
262 :return: True if the current-best solution can be queried.
264 See Also
265 - :meth:`get_best_f`
266 - :meth:`get_copy_of_best_x`
267 - :meth:`get_copy_of_best_y`
268 """
270 def get_best_f(self) -> int | float: # +book
271 """
272 Get the objective value of the current best solution.
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.
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`.
284 :return: the objective value of the current best solution.
286 See Also
287 - :meth:`has_best`
288 - :meth:`get_copy_of_best_x`
289 - :meth:`get_copy_of_best_y`
290 """
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.
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.
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.
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.
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`.
317 For understanding the relationship between the search space and the
318 solution space, see module :mod:`~moptipy.api.encoding`.
320 :param x: the destination data structure to be overwritten
322 See Also
323 - :meth:`has_best`
324 - :meth:`get_best_f`
325 - :meth:`get_copy_of_best_y`
326 """
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.
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.
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`.
342 :param y: the destination data structure to be overwritten
344 See Also
345 - :meth:`has_best`
346 - :meth:`get_best_f`
347 - :meth:`get_copy_of_best_x`
348 """
350 def get_last_improvement_fe(self) -> int: # +book
351 """
352 Get the FE at which the last improvement was made.
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`.
358 :return: the function evaluation when the last improvement was made
359 :raises ValueError: if no FE was performed yet
360 """
362 def get_last_improvement_time_millis(self) -> int:
363 """
364 Get the FE at which the last improvement was made.
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`.
370 :return: the function evaluation when the last improvement was made
371 :raises ValueError: if no FE was performed yet
372 """
374 def __str__(self) -> str:
375 """
376 Get the name of this process implementation.
378 This method is overwritten for each subclass of :class:`Process`
379 and then returns a short descriptive value of these classes.
381 :return: "process" for this base class
382 """
383 return "process"
385 def terminate(self) -> None: # +book
386 """
387 Terminate this process.
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 """
397 def has_log(self) -> bool:
398 """
399 Will any information of this process be logged?.
401 Only if this method returns `True`, invoking :meth:`add_log_section`
402 makes any sense. Otherwise, the data would just be discarded.
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 """
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).
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.
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`.
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.
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.
446 :param title: the title of the log section
447 :param text: the text to log
448 """
450 def get_log_basename(self) -> str | None:
451 """
452 Get the basename of the log, if any.
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.
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.
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
468 def initialize(self) -> None:
469 """
470 Raise an error because this method shall never be called.
472 :raises ValueError: always
473 """
474 raise ValueError("Never call the initialize() method of a Process!")
476 def __enter__(self) -> Self:
477 """
478 Begin a `with` statement.
480 :return: this process itself
481 """
482 return self
484 def __exit__(self, exception_type, exception_value, traceback) -> bool:
485 """
486 End a `with` statement.
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
497def check_max_fes(max_fes: int | None,
498 none_is_ok: bool = False) -> int | None:
499 """
500 Check the maximum FEs.
502 This is a small utility method that validates whether a maximum for the
503 objective function evaluations (FEs) is valid.
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)
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.
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.
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)
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.
547 This is a small utility method that validates whether a goal objective
548 value is valid.
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