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
« 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.
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
58from numpy.random import Generator
59from pycommons.types import check_int_range, type_error
61from moptipy.api.objective import Objective
62from moptipy.api.space import Space
65# start book
66class Process(Space, Objective, AbstractContextManager):
67 """
68 Processes offer data to the optimization algorithm and the user.
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 """
82# end book
84 def get_random(self) -> Generator: # +book
85 """
86 Obtain the random number generator.
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.
95 :return: the random number generator
96 """
98 def should_terminate(self) -> bool: # +book
99 """
100 Check whether the optimization process should terminate.
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.
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.
121 :return: `True` if the process should terminate, `False` if not
122 """
124 def evaluate(self, x) -> float | int: # +book
125 """
126 Evaluate a solution `x` and return its objective value.
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.
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.
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).
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`.
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`.
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`.)
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`.
180 :param x: the candidate solution
181 :return: the objective value
182 """
184 def register(self, x, f: int | float) -> None:
185 """
186 Register a solution `x` with externally-evaluated objective value.
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.
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`.
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.
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.
210 :param x: the candidate solution
211 :param f: the objective value
212 """
214 def get_consumed_fes(self) -> int:
215 """
216 Obtain the number consumed objective function evaluations.
218 This is the number of calls to :meth:`evaluate`.
220 :return: the number of objective function evaluations so far
221 """
223 def get_consumed_time_millis(self) -> int:
224 """
225 Obtain an approximation of the consumed runtime in milliseconds.
227 :return: the consumed runtime measured in milliseconds.
228 :rtype: int
229 """
231 def get_max_fes(self) -> int | None:
232 """
233 Obtain the maximum number of permitted objective function evaluations.
235 If no limit is set, `None` is returned.
237 :return: the maximum number of objective function evaluations,
238 or `None` if no limit is specified.
239 """
241 def get_max_time_millis(self) -> int | None:
242 """
243 Obtain the maximum runtime permitted in milliseconds.
245 If no limit is set, `None` is returned.
247 :return: the maximum runtime permitted in milliseconds,
248 or `None` if no limit is specified.
249 """
251 def has_best(self) -> bool: # +book
252 """
253 Check whether a current best solution is available.
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.
261 :return: True if the current-best solution can be queried.
263 See Also
264 - :meth:`get_best_f`
265 - :meth:`get_copy_of_best_x`
266 - :meth:`get_copy_of_best_y`
267 """
269 def get_best_f(self) -> int | float: # +book
270 """
271 Get the objective value of the current best solution.
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.
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`.
283 :return: the objective value of the current best solution.
285 See Also
286 - :meth:`has_best`
287 - :meth:`get_copy_of_best_x`
288 - :meth:`get_copy_of_best_y`
289 """
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.
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.
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.
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.
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`.
316 For understanding the relationship between the search space and the
317 solution space, see module :mod:`~moptipy.api.encoding`.
319 :param x: the destination data structure to be overwritten
321 See Also
322 - :meth:`has_best`
323 - :meth:`get_best_f`
324 - :meth:`get_copy_of_best_y`
325 """
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.
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.
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`.
341 :param y: the destination data structure to be overwritten
343 See Also
344 - :meth:`has_best`
345 - :meth:`get_best_f`
346 - :meth:`get_copy_of_best_x`
347 """
349 def get_last_improvement_fe(self) -> int: # +book
350 """
351 Get the FE at which the last improvement was made.
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`.
357 :return: the function evaluation when the last improvement was made
358 :raises ValueError: if no FE was performed yet
359 """
361 def get_last_improvement_time_millis(self) -> int:
362 """
363 Get the FE at which the last improvement was made.
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`.
369 :return: the function evaluation when the last improvement was made
370 :raises ValueError: if no FE was performed yet
371 """
373 def __str__(self) -> str:
374 """
375 Get the name of this process implementation.
377 This method is overwritten for each subclass of :class:`Process`
378 and then returns a short descriptive value of these classes.
380 :return: "process" for this base class
381 """
382 return "process"
384 def terminate(self) -> None: # +book
385 """
386 Terminate this process.
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 """
396 def has_log(self) -> bool:
397 """
398 Will any information of this process be logged?.
400 Only if this method returns `True`, invoking :meth:`add_log_section`
401 makes any sense. Otherwise, the data would just be discarded.
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 """
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).
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.
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`.
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.
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.
445 :param title: the title of the log section
446 :param text: the text to log
447 """
449 def get_log_basename(self) -> str | None:
450 """
451 Get the basename of the log, if any.
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.
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.
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
467 def initialize(self) -> None:
468 """
469 Raise an error because this method shall never be called.
471 :raises ValueError: always
472 """
473 raise ValueError("Never call the initialize() method of a Process!")
475 def __enter__(self) -> "Process":
476 """
477 Begin a `with` statement.
479 :return: this process itself
480 """
481 return self
483 def __exit__(self, exception_type, exception_value, traceback) -> bool:
484 """
485 End a `with` statement.
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
496def check_max_fes(max_fes: int | None,
497 none_is_ok: bool = False) -> int | None:
498 """
499 Check the maximum FEs.
501 This is a small utility method that validates whether a maximum for the
502 objective function evaluations (FEs) is valid.
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)
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.
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.
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)
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.
546 This is a small utility method that validates whether a goal objective
547 value is valid.
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