Coverage for moptipy / mock / components.py: 85%
523 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"""Generate random mock experiment parameters."""
3from dataclasses import dataclass
4from math import ceil, isfinite
5from string import digits
6from typing import Any, Final, Iterable
8from numpy.random import Generator
9from pycommons.io.console import logger
10from pycommons.types import check_int_range, type_error
12from moptipy.utils.nputils import rand_generator, rand_seeds_from_str
13from moptipy.utils.strings import sanitize_name
16def fixed_random_generator() -> Generator:
17 """
18 Get the single, fixed random generator for the dummy experiment API.
20 :returns: the random number generator
21 """
22 if not hasattr(fixed_random_generator, "gen"):
23 setattr(fixed_random_generator, "gen", rand_generator(1))
24 return getattr(fixed_random_generator, "gen")
27def _random_name(namelen: int,
28 random: Generator = fixed_random_generator()) -> str:
29 """
30 Generate a random name of a given length.
32 :param namelen: the length of the name
33 :param random: a random number generator
34 :returns: a name of the length
35 """
36 check_int_range(namelen, "namelen", 1, 1_000)
37 namer: Final[tuple[str, str, str]] = ("bcdfghjklmnpqrstvwxyz", "aeiou",
38 digits)
39 name = ["x"] * namelen
40 index: int = 0
41 n_done: bool = False
42 for i in range(namelen):
43 namee = namer[index]
44 name[i] = namee[random.integers(len(namee))]
45 n_done = n_done or (i == 2)
46 if index <= 0:
47 index += 1
48 else:
49 if (index == 2) and (int(random.integers(2)) <= 0):
50 continue
51 index = int((index + 1 + int(random.integers(2))) % len(namer))
52 if n_done:
53 while index == 2:
54 index = 1 - min(1, int(random.integers(6)))
56 return "".join(name)
59def __append_not_allowed(forbidden,
60 dest: set[str | float | int]) -> None:
61 """
62 Append items to the set of not-allowed values.
64 :param forbidden: the forbidden elements
65 :param dest: the set to append to
66 """
67 if not forbidden:
68 return
69 if isinstance(forbidden, str | int | float):
70 dest.add(forbidden)
71 elif isinstance(forbidden, Instance):
72 dest.add(forbidden.name)
73 dest.add(forbidden.hardness)
74 dest.add(forbidden.jitter)
75 dest.add(forbidden.scale)
76 dest.add(forbidden.best)
77 dest.add(forbidden.worst)
78 elif isinstance(forbidden, Algorithm):
79 dest.add(forbidden.name)
80 dest.add(forbidden.strength)
81 dest.add(forbidden.jitter)
82 elif isinstance(forbidden, Iterable):
83 for item in forbidden:
84 __append_not_allowed(item, dest)
85 else:
86 raise type_error(forbidden, "element to add",
87 (str, int, float, Instance, Algorithm, Iterable))
90def _make_not_allowed(forbidden: Iterable[str | float | int | Iterable[
91 Any]] | None = None) -> set[str | float | int]:
92 """
93 Create a set of not-allowed values.
95 :param forbidden: the forbidden elements
96 :returns: the set of not-allowed values
97 """
98 not_allowed: set[Any] = set()
99 __append_not_allowed(forbidden, not_allowed)
100 return not_allowed
103@dataclass(frozen=True, init=False, order=True)
104class Instance:
105 """An immutable instance description record."""
107 #: The instance name.
108 name: str
109 #: The instance hardness, in (0, 1), larger values are worst
110 hardness: float
111 #: The instance jitter, in (0, 1), larger values are worst
112 jitter: float
113 #: The instance scale, in (0, 1), larger values are worst
114 scale: float
115 #: The best (smallest) possible objective value
116 best: int
117 #: The worst (largest) possible objective value
118 worst: int
119 #: The set of attractors, i.e., local optima - including best and worst
120 attractors: tuple[int, ...]
122 def __init__(self,
123 name: str,
124 hardness: float,
125 jitter: float,
126 scale: float,
127 best: int,
128 worst: int,
129 attractors: tuple[int, ...]):
130 """
131 Create a mock problem instance description.
133 :param name: the instance name
134 :param hardness: the instance hardness
135 :param jitter: the instance jitter
136 :param scale: the instance scale
137 :param best: the best (smallest) possible objective value
138 :param worst: the worst (largest) possible objective value
139 :param attractors: the set of attractors, i.e., local
140 optima - including best and worst
141 """
142 if not isinstance(name, str):
143 raise type_error(name, "name", str)
144 if name != sanitize_name(name):
145 raise ValueError(f"Invalid name {name!r}.")
146 object.__setattr__(self, "name", name)
148 if not isinstance(hardness, float):
149 raise type_error(hardness, "hardness", float)
150 if (not isfinite(hardness)) or (hardness <= 0) or (hardness >= 1):
151 raise ValueError(
152 f"hardness must be in (0, 1), but is {hardness}.")
153 object.__setattr__(self, "hardness", hardness)
155 if not isinstance(jitter, float):
156 raise type_error(jitter, "jitter", float)
157 if (not isfinite(jitter)) or (jitter <= 0) or (jitter >= 1):
158 raise ValueError(
159 f"jitter must be in (0, 1), but is {jitter}.")
160 object.__setattr__(self, "jitter", jitter)
162 if not isinstance(scale, float):
163 raise type_error(scale, "scale", float)
164 if (not isfinite(scale)) or (scale <= 0) or (scale >= 1):
165 raise ValueError(
166 f"scale must be in (0, 1), but is {scale}.")
167 object.__setattr__(self, "scale", scale)
168 object.__setattr__(self, "best", check_int_range(
169 best, "best", 1, 1_000_000_000))
170 object.__setattr__(self, "worst", check_int_range(
171 worst, "worst", best + 8, 1_000_000_000))
173 if not isinstance(attractors, tuple):
174 raise type_error(attractors, "attractors", tuple)
175 if len(attractors) < 4:
176 raise ValueError("attractors must contain at least 2 values,"
177 f" but contains only {len(attractors)}.")
178 if attractors[0] != best:
179 raise ValueError(
180 f"attractors[0] must be {best}, but is {attractors[0]}")
181 if attractors[-1] != worst:
182 raise ValueError(
183 f"attractors[-1] must be {worst}, but is {attractors[-1]}")
184 prev = -1
185 for att in attractors:
186 check_int_range(att, "each attractor", max(best, prev + 1), worst)
187 prev = att
188 object.__setattr__(self, "attractors", attractors)
190 @staticmethod
191 def create(n: int,
192 forbidden: Any | None = None,
193 random: Generator = fixed_random_generator()) \
194 -> tuple["Instance", ...]:
195 """
196 Create a set of fixed problem instances.
198 :param n: the number of instances to generate
199 :param random: a random number generator
200 :param forbidden: the forbidden names and hardnesses
201 :returns: a tuple of instances
202 """
203 check_int_range(n, "n", 1, 1_000_000)
205 not_allowed: Final[set[str | float]] = \
206 _make_not_allowed(forbidden)
207 names: list[str] = []
208 hardnesses: list[float] = []
209 jitters: list[float] = []
210 scales: list[float] = []
212 # First we choose a unique name.
213 max_name_len: int = int(max(2, ceil(n / 6)))
214 trials: int = 0
215 while len(names) < n:
216 trials += 1
217 if trials > 1000:
218 trials = 1
219 max_name_len += 1
220 nv = _random_name(int(3 + random.integers(max_name_len)), random)
221 if nv not in not_allowed:
222 names.append(nv)
223 not_allowed.add(nv)
225 # Now we pick an instance hardness.
226 limit: int = 2000
227 trials = 0
228 while len(hardnesses) < n:
229 v: float = -1
230 while (v <= 0) or (v >= 1) or (not isfinite(v)):
231 trials += 1
232 if trials > 1000:
233 trials = 0
234 limit *= 2
235 v = (int(random.uniform(1, limit)) / limit) \
236 if random.integers(4) <= 0 else \
237 (int(random.normal(loc=0.5, scale=0.25) * limit) / limit)
238 if v not in not_allowed:
239 hardnesses.append(v)
240 not_allowed.add(v)
242 # Now we pick an instance jitter.
243 limit = 2000
244 trials = 0
245 while len(jitters) < n:
246 v = -1
247 while (v <= 0) or (v >= 1) or (not isfinite(v)):
248 trials += 1
249 if trials > 1000:
250 trials = 0
251 limit *= 2
252 v = (int(random.uniform(1, limit)) / limit) \
253 if random.integers(4) <= 0 else \
254 (int(random.normal(loc=0.5, scale=0.2) * limit) / limit)
255 if v not in not_allowed:
256 jitters.append(v)
257 not_allowed.add(v)
259 # Now we choose a scale.
260 limit = 2000
261 trials = 0
262 while len(scales) < n:
263 v = -1
264 while (v <= 0) or (v >= 1) or (not isfinite(v)):
265 trials += 1
266 if trials > 1000:
267 trials = 0
268 limit *= 2
269 v = (int(random.uniform(1, limit)) / limit) \
270 if random.integers(4) <= 0 else \
271 (int(random.normal(loc=0.5, scale=0.2) * limit) / limit)
272 if v not in not_allowed:
273 scales.append(v)
274 not_allowed.add(v)
276 # We choose the global optimum and the worst objective value.
277 trials = 0
278 scale: int = 1000
279 loc: int = 1000
280 limits: list[int] = []
281 while len(limits) < (2 * n):
282 tbound: int = -1
283 while (tbound <= 0) or (tbound >= 1_000_000_000):
284 trials += 1
285 if trials > 1000:
286 trials = 0
287 scale += (scale // 3)
288 loc *= 2
289 tbound = int(random.normal(
290 loc=random.integers(low=1, high=4) * loc, scale=scale))
291 permitted: bool = True
292 for b in limits:
293 if abs(b - tbound) <= 41:
294 permitted = False
295 break
296 if permitted and (tbound not in not_allowed):
297 limits.append(tbound)
298 not_allowed.add(tbound)
300 result: list[Instance] = []
301 attdone: set[int] = set()
303 for i in range(n, 0, -1):
304 trials = 0
305 b1 = b2 = -1
306 while trials < 10:
307 trials += 1
308 b1 = limits.pop(random.integers(i * 2))
309 b2 = limits.pop(random.integers(i * 2 - 1))
310 if b1 > b2:
311 b1, b2 = b2, b1
312 if i <= 1:
313 break
314 if (b1 * max(2.0, 0.4 * (10 - trials))) < b2:
315 break
316 limits.extend((b1, b2))
317 attdone.clear()
318 attdone.add(b1)
319 attdone.add(b2)
321 # Now we make sure that there are at least 6 attractors.
322 trials = 0
323 min_dist = max(7.0, 0.07 * (b2 - b1))
324 while ((len(attdone) < 6) or (random.integers(7) > 0)) \
325 and (trials < 20000):
326 a = -1
327 while ((a <= b1) or (a >= b2) or (a in attdone)) \
328 and (trials < 20000):
329 trials += 1
330 a = int(random.integers(low=b1 + 1, high=b2 - 1))
331 if (a <= b1) or (a >= b2):
332 continue
333 ok = True
334 for aa in attdone:
335 if abs(aa - a) < min_dist:
336 ok = False
337 break
338 if ok:
339 attdone.add(a)
340 elif (trials % 1000) <= 0:
341 min_dist = max(5, 0.5 * min_dist)
343 result.append(Instance(
344 name=names.pop(random.integers(i)),
345 hardness=hardnesses.pop(random.integers(i)),
346 jitter=jitters.pop(random.integers(i)),
347 scale=scales.pop(random.integers(i)),
348 best=b1,
349 worst=b2,
350 attractors=tuple(sorted(attdone))))
351 result.sort()
352 logger(f"finished creating {n} instances.")
353 return tuple(result)
356@dataclass(frozen=True, init=False, order=True)
357class Algorithm:
358 """An immutable algorithm description record."""
360 #: The algorithm name.
361 name: str
362 #: The algorithm strength, in (0, 1), larger values are worst
363 strength: float
364 #: The algorithm jitter, in (0, 1), larger values are worst
365 jitter: float
366 #: The algorithm complexity, in (0, 1), larger values are worst
367 complexity: float
369 def __init__(self,
370 name: str,
371 strength: float,
372 jitter: float,
373 complexity: float):
374 """
375 Create a mock algorithm description record.
377 :param name: the algorithm name
378 :param strength: the algorithm strength
379 :param jitter: the algorithm jitter
380 :param complexity: the algorithm complexity
381 """
382 if not isinstance(name, str):
383 raise type_error(name, "name", str)
384 if name != sanitize_name(name):
385 raise ValueError(f"Invalid name {name!r}.")
386 object.__setattr__(self, "name", name)
388 if not isinstance(strength, float):
389 raise type_error(strength, "strength", float)
390 if (not isfinite(strength)) or (strength <= 0) or (strength >= 1):
391 raise ValueError(
392 f"strength must be in (0, 1), but is {strength}.")
393 object.__setattr__(self, "strength", strength)
395 if not isinstance(jitter, float):
396 raise type_error(jitter, "jitter", float)
397 if (not isfinite(jitter)) or (jitter <= 0) or (jitter >= 1):
398 raise ValueError(
399 f"jitter must be in (0, 1), but is {jitter}.")
400 object.__setattr__(self, "jitter", jitter)
402 if not isinstance(complexity, float):
403 raise type_error(complexity, "complexity", float)
404 if (not isfinite(complexity)) or (complexity <= 0) \
405 or (complexity >= 1):
406 raise ValueError(
407 f"complexity must be in (0, 1), but is {complexity}.")
408 object.__setattr__(self, "complexity", complexity)
410 @staticmethod
411 def create(n: int,
412 forbidden: Any | None = None,
413 random: Generator = fixed_random_generator()) \
414 -> tuple["Algorithm", ...]:
415 """
416 Create a set of fixed mock algorithms.
418 :param n: the number of algorithms to generate
419 :param random: a random number generator
420 :param forbidden: the forbidden names and strengths and so on
421 :returns: a tuple of algorithms
422 """
423 check_int_range(n, "n", 1)
424 logger(f"now creating {n} algorithms.")
426 not_allowed: Final[set[str | float]] = \
427 _make_not_allowed(forbidden)
428 names: list[str] = []
429 strengths: list[float] = []
430 jitters: list[float] = []
431 complexities: list[float] = []
433 prefixes: Final[tuple[str, ...]] = ("aco", "bobyqa", "cmaes", "de",
434 "ea", "eda", "ga", "gp", "hc",
435 "ma", "pso", "rs", "rw", "sa",
436 "umda")
437 suffixes: Final[tuple[str, ...]] = ("1swap", "2swap", "µ") # noqa
439 max_name_len: int = int(max(2, ceil(n / 6)))
440 trials: int = 0
441 while len(names) < n:
442 trials += 1
443 if trials > 1000:
444 trials = 1
445 max_name_len += 1
447 name_mode = random.integers(5)
448 if name_mode < 2:
449 nva = _random_name(int(3 + random.integers(max_name_len)),
450 random)
451 if nva in not_allowed:
452 continue
453 if name_mode == 1:
454 nvb = suffixes[random.integers(len(suffixes))]
455 nv = f"{nva}_{nvb}"
456 else:
457 nv = nva
458 if nv in not_allowed:
459 continue
460 not_allowed.add(nva)
461 not_allowed.add(nv)
462 names.append(nv)
463 continue
465 nva = prefixes[random.integers(len(prefixes))]
466 if name_mode == 3:
467 nvb = _random_name(int(3 + random.integers(
468 max_name_len)), random)
469 if nvb in not_allowed:
470 continue
471 nv = f"{nva}_{nvb}"
472 elif name_mode == 4:
473 nvb = suffixes[random.integers(len(suffixes))]
474 nv = f"{nva}_{nvb}"
475 else:
476 nv = nva
477 nvb = ""
479 if nv in not_allowed:
480 continue
481 names.append(nv)
482 not_allowed.add(nv)
483 if name_mode == 3:
484 not_allowed.add(nvb)
486 limit: int = 2000
487 trials = 0
488 while len(strengths) < n:
489 v: float = -1
490 while (v <= 0) or (v >= 1) or (not isfinite(v)):
491 trials += 1
492 if trials > 1000:
493 trials = 0
494 limit *= 2
495 v = (int(random.uniform(1, limit)) / limit) \
496 if random.integers(4) <= 0 else \
497 (int(random.normal(loc=0.5, scale=0.25) * limit) / limit)
498 if v not in not_allowed:
499 strengths.append(v)
500 not_allowed.add(v)
502 limit = 2000
503 trials = 0
504 while len(jitters) < n:
505 v = -1
506 while (v <= 0) or (v >= 1) or (not isfinite(v)):
507 trials += 1
508 if trials > 1000:
509 trials = 0
510 limit *= 2
511 v = (int(random.uniform(1, limit)) / limit) \
512 if random.integers(4) <= 0 else \
513 (int(random.normal(loc=0.5, scale=0.2) * limit) / limit)
514 if v not in not_allowed:
515 jitters.append(v)
516 not_allowed.add(v)
518 limit = 2000
519 trials = 0
520 while len(complexities) < n:
521 v = -1
522 while (v <= 0) or (v >= 1) or (not isfinite(v)):
523 trials += 1
524 if trials > 1000:
525 trials = 0
526 limit *= 2
527 v = (int(random.uniform(1, limit)) / limit) \
528 if random.integers(4) <= 0 else \
529 (int(random.normal(loc=0.5, scale=0.2) * limit) / limit)
530 if v not in not_allowed:
531 complexities.append(v)
532 not_allowed.add(v)
534 result: list[Algorithm] = [
535 Algorithm(
536 name=names.pop(random.integers(i)),
537 strength=strengths.pop(random.integers(i)),
538 jitter=jitters.pop(random.integers(i)),
539 complexity=complexities.pop(random.integers(i)))
540 for i in range(n, 0, -1)]
541 result.sort()
543 logger(f"finished creating {n} algorithms.")
544 return tuple(result)
547@dataclass(frozen=True, init=False, order=True)
548class BasePerformance:
549 """An algorithm applied to a problem instance description record."""
551 #: the algorithm.
552 algorithm: Algorithm
553 #: the problem instance
554 instance: Instance
555 #: the base performance, in (0, 1), larger values are worst
556 performance: float
557 #: the performance jitter, in (0, 1), larger values are worst
558 jitter: float
559 #: the time per FE, in (0, 1), larger values are worst
560 speed: float
562 def __init__(self,
563 algorithm: Algorithm,
564 instance: Instance,
565 performance: float,
566 jitter: float,
567 speed: float):
568 """
569 Create a mock algorithm-instance application description record.
571 :param algorithm: the algorithm
572 :param instance: the instance
573 :param performance: the base performance
574 :param jitter: the performance jitter
575 :param speed: the time required per FE
576 """
577 if not isinstance(algorithm, Algorithm):
578 raise type_error(algorithm, "algorithm", Algorithm)
579 object.__setattr__(self, "algorithm", algorithm)
580 if not isinstance(instance, Instance):
581 raise type_error(instance, "instance", Instance)
582 object.__setattr__(self, "instance", instance)
584 if not isinstance(performance, float):
585 raise type_error(performance, "performance", float)
586 if (not isfinite(performance)) or (performance <= 0) \
587 or (performance >= 1):
588 raise ValueError(
589 f"performance must be in (0, 1), but is {performance}.")
590 object.__setattr__(self, "performance", performance)
592 if not isinstance(jitter, float):
593 raise type_error(jitter, "jitter", float)
594 if (not isfinite(jitter)) or (jitter <= 0) or (jitter >= 1):
595 raise ValueError(
596 f"jitter must be in (0, 1), but is {jitter}.")
597 object.__setattr__(self, "jitter", jitter)
599 if not isinstance(speed, float):
600 raise type_error(speed, "speed", float)
601 if (not isfinite(speed)) or (speed <= 0) or (speed >= 1):
602 raise ValueError(
603 f"speed must be in (0, 1), but is {speed}.")
604 object.__setattr__(self, "speed", speed)
606 @staticmethod
607 def create(instance: Instance,
608 algorithm: Algorithm,
609 random: Generator = fixed_random_generator()) \
610 -> "BasePerformance":
611 """
612 Compute the basic performance of an algorithm on a problem instance.
614 :param instance: the instance tuple
615 :param algorithm: the algorithm tuple
616 :param random: the random number generator
617 :returns: a tuple of the performance in (0, 1); bigger values are
618 worse, and a jitter in (0, 1), where bigger values are worse
619 """
620 if not isinstance(instance, Instance):
621 raise type_error(instance, "instance", Instance)
622 if not isinstance(algorithm, Algorithm):
623 raise type_error(algorithm, "algorithm", Algorithm)
624 logger("now creating base performance for algorithm "
625 f"{algorithm.name} on instance {instance.name}.")
627 perf: float = -1
628 granularity: Final[int] = 2000
629 while (perf <= 0) or (perf >= 1):
630 perf = random.uniform(low=0, high=1) \
631 if random.integers(20) <= 0 else \
632 random.normal(
633 loc=0.5 * (instance.hardness + algorithm.strength),
634 scale=0.2 * (instance.jitter + algorithm.jitter))
635 perf = int(perf * granularity) / granularity
637 jit: float = -1
638 while (jit <= 0) or (jit >= 1):
639 jit = random.uniform(low=0, high=1) \
640 if random.integers(15) <= 0 else \
641 random.normal(
642 loc=0.5 * (instance.jitter + algorithm.jitter),
643 scale=0.2 * (instance.jitter + algorithm.jitter))
644 jit = int(jit * granularity) / granularity
646 speed: float = -1
647 while (speed <= 0) or (speed >= 1):
648 speed = random.uniform(low=0, high=1) \
649 if random.integers(20) <= 0 else \
650 random.normal(
651 loc=0.5 * (instance.scale + algorithm.complexity),
652 scale=0.2 * (instance.scale + algorithm.complexity))
653 speed = int(speed * granularity) / granularity
655 bp: Final[BasePerformance] = BasePerformance(algorithm=algorithm,
656 instance=instance,
657 performance=perf,
658 jitter=jit,
659 speed=speed)
660 logger("finished base performance "
661 f"{bp.algorithm.name}@{bp.instance.name}.")
662 return bp
665def get_run_seeds(instance: Instance, n_runs: int) -> tuple[int, ...]:
666 """
667 Get the seeds for the runs.
669 :param instance: the mock instance
670 :param n_runs: the number of runs
671 :returns: a tuple of seeds
672 """
673 if not isinstance(instance, Instance):
674 raise type_error(instance, "instance", Instance)
675 check_int_range(n_runs, "n_runs", 1, 1_000_000)
676 res: Final[tuple[int, ...]] = tuple(sorted(rand_seeds_from_str(
677 string=instance.name, n_seeds=n_runs)))
678 logger(f"finished creating {n_runs} seeds for instance {instance.name}.")
679 return res
682@dataclass(frozen=True, init=False, order=True)
683class Experiment:
684 """An immutable experiment description."""
686 #: The instances.
687 instances: tuple[Instance, ...]
688 #: The algorithms.
689 algorithms: tuple[Algorithm, ...]
690 #: The applications of the algorithms to the instances.
691 applications: tuple[BasePerformance, ...]
692 #: The random seeds per instance.
693 per_instance_seeds: tuple[tuple[int, ...]]
694 #: the seeds per instance
695 __seeds_per_inst: dict[str | Instance, tuple[int, ...]]
696 #: the performance per algorithm
697 __perf_per_algo: dict[str | Algorithm, tuple[BasePerformance, ...]]
698 #: the performance per instance
699 __perf_per_inst: dict[str | Instance, tuple[BasePerformance, ...]]
700 #: the algorithm by names
701 __algo_by_name: dict[str, Algorithm]
702 #: the algorithm names
703 algorithm_names: tuple[str, ...]
704 #: the instance by names
705 __inst_by_name: dict[str, Instance]
706 #: the instance names
707 instance_names: tuple[str, ...]
709 def __init__(self,
710 instances: tuple[Instance, ...],
711 algorithms: tuple[Algorithm, ...],
712 applications: tuple[BasePerformance, ...],
713 per_instance_seeds: tuple[tuple[int, ...], ...]):
714 """
715 Create a mock experiment definition.
717 :param algorithms: the algorithms
718 :param instances: the instances
719 :param applications: the applications of the algorithms to the
720 instances
721 :param per_instance_seeds: the seeds
722 """
723 if not isinstance(instances, tuple):
724 raise type_error(instances, "instances", tuple)
725 if len(instances) <= 0:
726 raise ValueError("instances must not be empty.")
727 inst_bn: dict[str, Instance] = {}
728 for a in instances:
729 if not isinstance(a, Instance):
730 raise type_error(a, "element of instances", Instance)
731 if a.name in inst_bn:
732 raise ValueError(f"double instance name {a.name}.")
733 inst_bn[a.name] = a
734 object.__setattr__(self, "instances", instances)
735 object.__setattr__(self, "_Experiment__inst_by_name", inst_bn)
736 object.__setattr__(self, "instance_names",
737 tuple(sorted(inst_bn.keys())))
739 if not isinstance(algorithms, tuple):
740 raise type_error(algorithms, "algorithms", tuple)
741 if len(algorithms) <= 0:
742 raise ValueError("algorithms must not be empty.")
743 algo_bn: dict[str, Algorithm] = {}
744 for b in algorithms:
745 if not isinstance(b, Algorithm):
746 raise type_error(b, "element of algorithms", Algorithm)
747 if b.name in algo_bn:
748 raise ValueError(f"double algorithm name {b.name}.")
749 if b.name in inst_bn:
750 raise ValueError(f"instance/algorithm name {b.name} clash.")
751 algo_bn[b.name] = b
752 object.__setattr__(self, "algorithms", algorithms)
753 object.__setattr__(self, "_Experiment__algo_by_name", algo_bn)
754 object.__setattr__(self, "algorithm_names",
755 tuple(sorted(algo_bn.keys())))
757 if not isinstance(applications, tuple):
758 raise type_error(applications, "applications", tuple)
759 if len(applications) != len(algorithms) * len(instances):
760 raise ValueError(
761 f"There must be {len(algorithms) * len(instances)} "
762 f"applications, but found {len(applications)}.")
764 perf_per_inst: dict[Instance, list[BasePerformance]] = {}
765 perf_per_algo: dict[Algorithm, list[BasePerformance]] = {}
767 done: set[str] = set()
768 for c in applications:
769 if not isinstance(c, BasePerformance):
770 raise type_error(c, "element of applications", BasePerformance)
771 s = c.algorithm.name + "+" + c.instance.name
772 if s in done:
773 raise ValueError(f"Encountered application {s} twice.")
774 done.add(s)
775 if c.algorithm in perf_per_algo:
776 perf_per_algo[c.algorithm].append(c)
777 else:
778 perf_per_algo[c.algorithm] = [c]
779 if c.instance in perf_per_inst:
780 perf_per_inst[c.instance].append(c)
781 else:
782 perf_per_inst[c.instance] = [c]
783 object.__setattr__(self, "applications", applications)
785 pa: dict[str | Algorithm, tuple[BasePerformance, ...]] = {}
786 for ax in algorithms:
787 lax: list[BasePerformance] = perf_per_algo[ax]
788 lax.sort()
789 pa[ax.name] = pa[ax] = tuple(lax)
790 pi: dict[str | Instance, tuple[BasePerformance, ...]] = {}
791 for ix in instances:
792 lix: list[BasePerformance] = perf_per_inst[ix]
793 lix.sort()
794 pi[ix.name] = pi[ix] = tuple(lix)
795 object.__setattr__(self, "_Experiment__perf_per_algo", pa)
796 object.__setattr__(self, "_Experiment__perf_per_inst", pi)
798 if not isinstance(per_instance_seeds, tuple):
799 raise type_error(per_instance_seeds, "per_instance_seeds", tuple)
800 if len(per_instance_seeds) != len(instances):
801 raise ValueError(
802 f"There must be one entry for each of the {len(instances)} "
803 "instances, but per_instance_seeds only "
804 f"has {len(per_instance_seeds)}.")
805 xl: int = -1
806 inst_seeds: Final[dict[str | Instance, tuple[int, ...]]] = {}
807 for idx, d in enumerate(per_instance_seeds):
808 if not isinstance(d, tuple):
809 raise type_error(d, "element of per_instance_seeds", tuple)
810 if len(d) <= 0:
811 raise ValueError(f"there must be at least one per "
812 f"instance seed, but found {len(d)}.")
813 if xl < 0:
814 xl = len(d)
815 if len(d) != xl:
816 raise ValueError(f"there must be {xl} per "
817 f"instance seeds, but found {len(d)}.")
818 for e in d:
819 if not isinstance(e, int):
820 raise type_error(e, "element of seeds", int)
821 inst_seeds[instances[idx]] = d
822 inst_seeds[instances[idx].name] = d
823 object.__setattr__(self, "per_instance_seeds", per_instance_seeds)
824 object.__setattr__(self, "_Experiment__seeds_per_inst", inst_seeds)
826 @staticmethod
827 def create(n_instances: int,
828 n_algorithms: int,
829 n_runs: int,
830 random: Generator = fixed_random_generator()) -> "Experiment":
831 """
832 Create an experiment definition.
834 :param n_instances: the number of instances
835 :param n_algorithms: the number of algorithms
836 :param n_runs: the number of per-instance runs
837 :param random: the random number generator to use
838 """
839 check_int_range(n_instances, "n_instances", 1, 1_000_000)
840 check_int_range(n_algorithms, "n_algorithms", 1, 1_000_000)
841 check_int_range(n_runs, "n_runs", 1, 1_000_000)
842 logger(f"now creating mock experiment with {n_algorithms} algorithms "
843 f"on {n_instances} instances for {n_runs} runs.")
845 insts = Instance.create(n_instances, random=random)
846 algos = Algorithm.create(n_algorithms, forbidden=insts, random=random)
847 app = [BasePerformance.create(i, a, random)
848 for i in insts for a in algos]
849 app.sort()
850 seeds = [get_run_seeds(i, n_runs) for i in insts]
851 res: Final[Experiment] = Experiment(instances=insts,
852 algorithms=algos,
853 applications=tuple(app),
854 per_instance_seeds=tuple(seeds))
855 logger(f"finished creating mock experiment with {len(res.instances)} "
856 f"instances, {len(res.algorithms)} algorithms, and "
857 f"{len(res.per_instance_seeds[0])} runs per instance-"
858 f"algorithm combination.")
859 return res
861 def seeds_for_instance(self, instance: str | Instance) \
862 -> tuple[int, ...]:
863 """
864 Get the seeds for the specified instance.
866 :param instance: the instance
867 :returns: the seeds
868 """
869 return self.__seeds_per_inst[instance]
871 def instance_applications(self, instance: str | Instance) \
872 -> tuple[BasePerformance, ...]:
873 """
874 Get the applications of the algorithms to a specific instance.
876 :param instance: the instance
877 :returns: the applications
878 """
879 return self.__perf_per_inst[instance]
881 def algorithm_applications(self, algorithm: str | Algorithm) \
882 -> tuple[BasePerformance, ...]:
883 """
884 Get the applications of an algorithm to the instances.
886 :param algorithm: the algorithm
887 :returns: the applications
888 """
889 return self.__perf_per_algo[algorithm]
891 def get_algorithm(self, name: str) -> Algorithm:
892 """
893 Get an algorithm by name.
895 :param name: the algorithm name
896 :returns: the algorithm instance
897 """
898 return self.__algo_by_name[name]
900 def get_instance(self, name: str) -> Instance:
901 """
902 Get an instance by name.
904 :param name: the instance name
905 :returns: the instance
906 """
907 return self.__inst_by_name[name]