Coverage for moptipy / evaluation / selector.py: 93%
330 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-14 07:09 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-14 07:09 +0000
1"""
2A tool for selecting a consistent subset of data from partial experiments.
4When we have partial experimental data, maybe collected from experiments that
5are still ongoing, we want to still evaluate them in some consistent way. The
6right method for doing this could be to select a subset of that data that is
7consistent, i.e., a subset where the algorithms have the same number of runs
8on the instances using the same seeds. The function :func:`select_consistent`
9offered by this module provides the functionality to make such a selection.
10It may be a bit slow, but hopefully it will pick the largest possible
11consistent sub-selection or, at least, get close to it.
13Our :func:`select_consistent` tries to find such a consistent dataset using
14different :class:`Selector` methods that are passed to it as second parameter.
16In the most basic and defaul setting, :const:`SELECTOR_SAME_RUNS_FOR_ALL`, it
17simply retains the exactly same number of runs for all
18instance/algorithm/objective/encoding combinations, making sure that the same
19seeds are used for a given instance.
20This can be done rather quickly, but it may not yield the largest possible
21set of consistent runs.
22It will also fail if there are some combinations that have runs with mutually
23distinct seeds.
25In this case, using :const:`SELECTOR_MAX_RUNS_SIMPLE` might be successful, as
26it is a more heuristic approach.
27The heuristic methods implemented here always begin with the full set of data
28and aim to delete the element that will cause the least other deletions down
29the road, until we arrive in a consistent state.
30I strongly suspect that doing this perfectly would be NP-hard, so we cannot
31implement this in a perfect way. Instead, we use different heuristics and then
32pick the best result.
33:const:`SELECTOR_MAX_RUNS` uses more heuristics, which means that it will be
34slower, but may have a bigger chance to yield a consistent experiment.
35"""
37from typing import Any, Final, Iterable, TypeVar
39from pycommons.io.console import logger
40from pycommons.types import type_error
42from moptipy.evaluation.base import PerRunData
44#: the type variable for the selector routine
45T = TypeVar("T", bound=PerRunData)
47#: the algorithm key
48KEY_ALGORITHM: Final[int] = 0
49#: the instance key
50KEY_INSTANCE: Final[int] = KEY_ALGORITHM + 1
51#: the encoding key
52KEY_ENCODING: Final[int] = KEY_INSTANCE + 1
53#: the objective key
54KEY_OBJECTIVE: Final[int] = KEY_ENCODING + 1
55#: the seed key
56KEY_SEED: Final[int] = KEY_OBJECTIVE + 1
57#: the number of keys
58TOTAL_KEYS: Final[int] = KEY_SEED + 1
61class Selector:
62 """
63 The base class for selectors.
65 Each selector offers a routine that can select a subset of runs from a
66 given description. For the selection process, all instances, objective
67 functions, encodings, and algorithms as well as (instance-seed)
68 combinations are replaced with integer numbers identifying them.
70 The data that is passed to the method :meth:`select` is already purified.
71 Algorithm, instance, objective function, and encoding names are replaced
72 with consecutive integers. Instance/seed combinations are also replaced
73 with such integer values.
74 Each run of the original experiment is thus identified by a five-tuple of
75 integer values.
76 """
78 def select(self,
79 dims: tuple[int, int, int, int, int],
80 keys: tuple[tuple[int, int, int, int, int], ...],
81 best_length: int) -> list[tuple[
82 int, int, int, int, int]] | None:
83 """
84 Perform the dataset selection.
86 The `keys` array is an immutable list of keys. Each key uniquely
87 identifies one run in the experiment. It is a tuple of five integer
88 values:
90 0. The value at index :const:`KEY_ALGORITHM` identifies the
91 algorithm used in the run,
92 1. the value at index :const:`KEY_INSTANCE` identifies the instance,
93 2. the value at index :const:`KEY_OBJECTIVE` identifies the objective
94 function,
95 3. the value at index :const:`KEY_ENCODING` identifies the encoding,
96 and
97 4. the value at index :const:`KEY_SEED` identifies the instance-seed
98 combination, meaning that each seed on each instance maps to a
99 unique value here.
101 The number of values in each key dimension is given in the array
102 `dims`. The instance identifiers, for example, are all in the interval
103 `0..dims[KEY_INSTANCE]-1`. Because of this, you can use the key values
104 as indexes into consecutive lists to keep track of counts, if you want
105 to.
107 The parameter `best_length` provides the largest number of consistent
108 runs discovered by any selection method so far. It is `-1` for the
109 first selector that is attempted.
110 The selection routines returns a list of selected run keys, or `None`
111 if this selector could not surpass `best_length`.
113 :param dims: the number of different values per key dimension
114 :param keys: the data keys to select from
115 :param best_length: the largest number of consistent runs discovered
116 by any previously applied selector.
117 :return: the selected data
118 """
119 raise NotImplementedError
122def _count_inc(scores: list[list[int]], key: tuple[int, ...]) -> None:
123 """
124 Increment the score of a given tuple.
126 :param scores: the scores
127 :param key: the key
128 """
129 for i, k in enumerate(key):
130 scores[i][k] += 1
133def _count_dec(scores: list[list[int]], key: tuple[int, ...]) -> None:
134 """
135 Decrement the score of a given tuple.
137 :param scores: the scores
138 :param key: the key
139 """
140 for i, k in enumerate(key):
141 scores[i][k] -= 1
144class HeuristicSelector(Selector):
145 """A heuristic selector."""
147 def weight(self, counts: list[list[int]]) -> Any: # pylint: disable=W0613
148 """
149 Compute a weight for the scores.
151 This function can be overwritten to compute the total score.
153 :param counts: the number of currently selected runs for each
154 dimension and each dimension value
155 :return: a weight factor, that may be ignored by some methods
156 """
157 return 1
159 def score(self, element: tuple[int, ...], # pylint: disable=W0613
160 counts: list[list[int]], # pylint: disable=W0613
161 weights: Any) -> Any: # pylint: disable=W0613
162 """
163 Score a given element.
165 :param element: the tuple to score
166 :param counts: the number of currently selected runs for each
167 dimension and each dimension value
168 :param weights: the weights, which may sometimes be ignored
169 :returns: the score
170 """
171 return None
173 def select(self,
174 dims: tuple[int, int, int, int, int],
175 keys: tuple[tuple[int, int, int, int, int], ...],
176 best_length: int) -> list[tuple[
177 int, int, int, int, int]] | None:
178 """
179 Perform the heuristic dataset selection.
181 Heuristic selectors attempt to step-by-step delete keys that lead to
182 the fewest dropped instances, algorithms, objectives, and encodings.
184 :param dims: the number of different values per key dimension
185 :param keys: the data keys to select from
186 :param best_length: the length of the best-so-far set of runs,
187 will be `-1` if no such best exists
188 :return: the selected data
189 """
190 # the counters for the score calculations
191 source: Final[list[list]] = [[k, 0] for k in keys]
192 dataset_size: Final[int] = list.__len__(source)
193 scorer_name: Final[str] = str(self)
195 # compute the original item scores
196 counts: list[list[int]] = [[0] * i for i in dims]
197 count: int = dataset_size
198 for er in source:
199 _count_inc(counts, er[0])
201 changed: bool = True
202 while changed:
203 changed = False
205 # Find the setups with maximum and minimum scores.
206 min_score: Any = None
207 max_score: Any = None
208 weights: Any = self.weight(counts)
210 if weights is None: # cannot proceed
211 logger(f"Method {scorer_name!r} is not applicable.")
212 return None
214 for er in source:
215 er[1] = score = self.score( # pylint: disable=E1128
216 er[0], counts, weights)
217 if score is not None:
218 if (min_score is None) or (min_score > score):
219 min_score = score
220 if (max_score is None) or (max_score < score):
221 max_score = score
222 del weights
223 if min_score is None:
224 logger(f"{scorer_name!r} failed.")
225 return None
226 if min_score < max_score: # Some setups have lower scores.
227 del_count: int = 0 # We will delete them.
228 for i in range(count - 1, -1, -1):
229 er = source[i]
230 score = er[1]
231 if (score is None) or (score <= min_score):
232 del source[i]
233 _count_dec(counts, er[0])
234 del_count += 1
235 if del_count <= 0:
236 raise ValueError(
237 f"Did not delete anything under {scorer_name!r}?")
239 new_count: int = list.__len__(source)
240 if new_count != (count - del_count):
241 raise ValueError("List inconsistent after deletion "
242 f"under {scorer_name!r}?")
243 logger(f"Deleted {del_count} of the {count} records because "
244 f"their score was {min_score}. Retained {new_count} "
245 f"records under {scorer_name!r}.")
246 count = new_count
247 changed = True
248 continue
250 logger(f"All setups now have the same score {max_score} under"
251 f" {scorer_name!r}.")
252 if count <= best_length:
253 logger(f"We now only have {count} setups, which means we "
254 "cannot get better than the current best set with "
255 f"{best_length} setups, so we quit.")
256 return None
258 # If we get here, all elements have the same score.
259 # This means that we are basically done.
260 #
261 # However, this may also happen if a very odd division exists in
262 # the data. Maybe we have one algorithm that was applied to one
263 # instance ten times and another algorithm applied to another
264 # instance ten times. This data would still be inconsistent, as it
265 # does not allow for any comparison.
267 # Compute the different values for everything except the seeds.
268 usekeys: tuple[int, ...] = tuple(
269 v for v, s in enumerate(counts)
270 if (v != KEY_SEED) and (len(s) > 1))
272 if tuple.__len__(usekeys) > 1:
273 logger("Now checking for inconsistencies in "
274 "algorithm/instance/objective/encoding under"
275 f" {scorer_name!r}.")
276 # Only if there are different values in at least one tuple
277 # dimension, we need to check for strange situations.
279 # For each of (instance, algorithm, encoding, objective), we
280 # must have the same number of other records.
281 # If not, then we have some strange symmetric situation that
282 # needs to be solved.
283 per_value: dict[int, set[Any]] = {}
284 last: set[Any] | None = None
285 for key in usekeys:
286 other_keys: tuple[int, ...] = tuple(
287 kk for kk in usekeys if kk != key)
288 for er in source:
289 kv = er[0][key]
290 other = tuple(er[0][ok] for ok in other_keys)
291 if kv in per_value:
292 per_value[kv].add(other)
293 else:
294 per_value[kv] = last = {other}
295 for v in per_value.values():
296 if v != last:
297 changed = True
298 break
299 per_value.clear()
301 # We just need to delete one random element. This will
302 # break the symmetry
303 if changed:
304 logger(
305 f"Deleting one of the {count} elements to "
306 "break the erroneous symmetry under "
307 f"{scorer_name}.")
308 er = source[-1]
309 del source[-1]
310 _count_dec(counts, er[0])
311 count -= 1
312 break
313 del per_value
314 del usekeys
315 if changed:
316 continue
318 if count <= best_length:
319 logger(f"We now only have {count} setups, which means we "
320 "cannot get better than the current best set with "
321 f"{best_length} setups, so we quit.")
322 return None
324 # If we get here, the only problem left could be if algorithms
325 # have different seeds for the same instances. We thus need to
326 # check that for each instance, all the seeds are the same.
327 # Notice that such inconsistencies can only occur if different
328 # seeds occurred exactly as same as often.
329 logger("Now checking for inconsistencies in instance "
330 f"seeds under {scorer_name!r}.")
331 seeds: dict[int, dict[tuple[int, int, int], set[int]]] = {}
332 for er in source:
333 inst: int = er[0][KEY_INSTANCE]
334 cur: dict[tuple[int, int, int], set[int]]
335 if inst in seeds:
336 cur = seeds[inst]
337 else:
338 seeds[inst] = cur = {}
339 kx: tuple[int, int, int] = (
340 er[0][KEY_ALGORITHM], er[0][KEY_OBJECTIVE],
341 er[0][KEY_ENCODING])
342 if kx in cur:
343 cur[kx].add(er[0][KEY_SEED])
344 else:
345 cur[kx] = {er[0][KEY_SEED]}
347 max_seed_insts: set[int] = set()
348 max_seeds: int = -1
349 min_seed_inst: int | None = None
350 min_seeds: int = -1
351 must_delete_from_insts: set[int] = set()
352 for instance, inst_setups in seeds.items():
353 kvx: set[int] | None = None
354 for setup_seeds in inst_setups.values():
355 if kvx is None:
356 kvx = setup_seeds
357 seed_cnt: int = set.__len__(setup_seeds)
358 if seed_cnt >= max_seeds:
359 if seed_cnt > max_seeds:
360 max_seed_insts.clear()
361 max_seeds = seed_cnt
362 max_seed_insts.add(instance)
363 if (seed_cnt < min_seeds) or (min_seed_inst is None):
364 min_seeds = seed_cnt
365 min_seed_inst = instance
366 elif kvx != setup_seeds:
367 # We got a symmetric inconsistency
368 must_delete_from_insts.add(instance)
369 break
370 if min_seeds < max_seeds:
371 must_delete_from_insts.update(max_seed_insts)
372 del max_seed_insts
374 del_count = set.__len__(must_delete_from_insts)
375 if del_count > 0:
376 logger("Must delete one record from all of "
377 f"{must_delete_from_insts!r}.")
378 for i in range(count - 1, -1, -1):
379 er = source[i]
380 instance = er[0][KEY_INSTANCE]
381 if instance in must_delete_from_insts:
382 must_delete_from_insts.remove(instance)
383 del source[i]
384 _count_dec(counts, er[0])
385 changed = True
386 if set.__len__(must_delete_from_insts) <= 0:
387 break
388 new_count = list.__len__(source)
389 if new_count != (count - del_count):
390 raise ValueError("Error when deleting instances "
391 f"under {scorer_name!r}.")
392 count = new_count
393 if not changed:
394 raise ValueError(
395 f"Seeds inconsistent under {scorer_name!r}.")
396 del must_delete_from_insts
398 del seeds
399 if count <= best_length:
400 logger(f"We now only have {count} setups, which "
401 "means we cannot get better than the current "
402 f"best set with {best_length} setups, so we "
403 "quit.")
404 return None
405 # There should not be any problems left, but we need to check
406 # again if something has changed.
408 if count <= best_length:
409 logger(f"We now only have {count} setups, which means we "
410 "cannot get better than the current best set with "
411 f"{best_length} setups, so we quit..")
412 return None # We can do nothing here
413 # We are finally finished. The scores are no longer needed.
414 del counts
415 if count <= 0:
416 raise ValueError("No data found at all?")
417 return [b[0] for b in source]
420class __HeuristicSelectorTiered(HeuristicSelector):
421 """A tiered heuristic selector."""
423 def score(self, element: tuple[int, ...], counts: list[list[int]],
424 _: Any) -> list[int]:
425 """
426 Score a given element.
428 :param element: the tuple to score
429 :param counts: the element counts per dimension
430 :param _: the weights, which may sometimes be ignored
431 :returns: the score
432 """
433 return sorted(counts[i][t] for i, t in enumerate(element))
435 def __str__(self) -> str:
436 """
437 Get the selector name.
439 :returns "tiered": always
440 """
441 return "tiered"
444class __HeuristicSelectorTieredReverse(HeuristicSelector):
445 """A reverse tiered heuristic selector."""
447 def score(self, element: tuple[int, ...], counts: list[list[int]],
448 _: Any) -> list[int]:
449 """
450 Score a given element.
452 :param element: the tuple to score
453 :param counts: the element counts per dimension
454 :param _: the weights, which may sometimes be ignored
455 :returns: the score
456 """
457 return sorted((counts[i][t] for i, t in enumerate(element)),
458 reverse=True)
460 def __str__(self) -> str:
461 """
462 Get the selector name.
464 :returns "tieredReverse": always
465 """
466 return "tieredReverse"
469class __HeuristicSelectorSum(HeuristicSelector):
470 """A sum heuristic selector."""
472 def score(self, element: tuple[int, ...], counts: list[list[int]],
473 _: Any) -> int:
474 """
475 Score a given element.
477 :param element: the tuple to score
478 :param counts: the element counts per dimension
479 :param _: the weights, which may sometimes be ignored
480 :returns: the score
481 """
482 return sum(counts[i][t] for i, t in enumerate(element))
484 def __str__(self) -> str:
485 """
486 Get the selector name.
488 :returns "sum": always
489 """
490 return "sum"
493class __HeuristicSelectorProduct(HeuristicSelector):
494 """A product heuristic selector."""
496 def score(self, element: tuple[int, ...], counts: list[list[int]],
497 _: Any) -> int:
498 """
499 Score a given element.
501 :param element: the tuple to score
502 :param counts: the element counts per dimension
503 :param _: the weights, which may sometimes be ignored
504 :returns: the score
505 """
506 result: int = 1
507 for i, t in enumerate(element):
508 result *= counts[i][t]
509 return result
511 def __str__(self) -> str:
512 """
513 Get the selector name.
515 :returns "product": always
516 """
517 return "product"
520class __HeuristicSelectorMinimum(HeuristicSelector):
521 """A minimum heuristic selector."""
523 def score(self, element: tuple[int, ...], counts: list[list[int]],
524 _: Any) -> int:
525 """
526 Score a given element.
528 :param element: the tuple to score
529 :param counts: the element counts per dimension
530 :param _: the weights, which may sometimes be ignored
531 :returns: the score
532 """
533 return min(element)
535 def __str__(self) -> str:
536 """
537 Get the selector name.
539 :returns "min": always
540 """
541 return "min"
544class __NormalizedHeuristicSelector(HeuristicSelector):
545 """A heuristic selector."""
547 def weight(self, counts: list[list[int]]) -> list[int] | None:
548 """
549 Compute the total score.
551 This function can be overwritten to compute the total score.
553 :param counts: the counts
554 :return: a weight factor, that may be ignored by some methods
555 """
556 result: list[int] = [sum(t) for t in counts]
557 return None if (list.__len__(result) < 1) or (
558 max(result) <= 0) else result
561class __HeuristicSelectorNormTiered(__NormalizedHeuristicSelector):
562 """A normalized tiered heuristic selector."""
564 def score(self, element: tuple[int, ...], counts: list[list[int]],
565 weights: Any) -> list[float]:
566 """
567 Score a given element.
569 :param element: the tuple to score
570 :param counts: the element counts per dimension
571 :param weights: the weights
572 :returns: the score
573 """
574 return sorted(counts[i][t] / weights[i] for i, t in enumerate(
575 element) if weights[i] > 0)
577 def __str__(self) -> str:
578 """
579 Get the selector name.
581 :returns "normalizedTiered": always
582 """
583 return "normalizedTiered"
586class __HeuristicSelectorNormTieredRev(__NormalizedHeuristicSelector):
587 """A normalized tiered reverse heuristic selector."""
589 def score(self, element: tuple[int, ...], counts: list[list[int]],
590 weights: Any) -> list[float]:
591 """
592 Score a given element.
594 :param element: the tuple to score
595 :param counts: the element counts per dimension
596 :param weights: the weights
597 :returns: the score
598 """
599 return sorted((counts[i][t] / weights[i] for i, t in enumerate(
600 element) if weights[i] > 0), reverse=True)
602 def __str__(self) -> str:
603 """
604 Get the selector name.
606 :returns "normalizedTieredReverse": always
607 """
608 return "normalizedTieredReverse"
611class __HeuristicSelectorNormSum(__NormalizedHeuristicSelector):
612 """A normalized sum heuristic selector."""
614 def score(self, element: tuple[int, ...], counts: list[list[int]],
615 weights: Any) -> float:
616 """
617 Score a given element.
619 :param element: the tuple to score
620 :param counts: the element counts per dimension
621 :param weights: the weights
622 :returns: the score
623 """
624 return sum(counts[i][t] / weights[i] for i, t in enumerate(
625 element) if weights[i] > 0)
627 def __str__(self) -> str:
628 """
629 Get the selector name.
631 :returns "normalizedSum": always
632 """
633 return "normalizedSum"
636class __SameNumberOfRuns(Selector):
637 """A selector choosing the same runs for all instance/algorithm combos."""
639 def select(self,
640 dims: tuple[int, int, int, int, int],
641 keys: tuple[tuple[int, int, int, int, int], ...],
642 best_length: int) -> list[tuple[
643 int, int, int, int, int]] | None:
644 """
645 Perform the dataset selection.
647 :param dims: the number of different values per key dimension
648 :param keys: the data keys to select from
649 :param best_length: the length of the best-so-far set of runs,
650 will be `-1` if no such best exists
651 :return: the selected data
652 """
653 runs: list[dict[tuple[int, int, int], set[int]]] = [
654 {} for _ in range(dims[KEY_INSTANCE])]
655 n_combos: int = 0
656 for key in keys:
657 dct = runs[key[KEY_INSTANCE]]
658 subkey = (key[KEY_ALGORITHM], key[KEY_ENCODING],
659 key[KEY_OBJECTIVE])
660 if subkey in dct:
661 dct[subkey].add(key[KEY_SEED])
662 else:
663 dct[subkey] = {key[KEY_SEED]}
664 n_combos += 1
666 if n_combos <= 0:
667 raise ValueError("No runs??")
669 inst_seeds: list[set[int]] = [
670 next(iter(dct.values())) for dct in runs]
671 min_set_len: int = tuple.__len__(keys)
672 for i, dct in enumerate(runs):
673 base = inst_seeds[i]
674 for st in dct.values():
675 base.intersection_update(st)
676 sl: int = set.__len__(base)
677 if sl < min_set_len:
678 if sl <= 0:
679 logger("Cannot find non-empty instance/run intersection.")
680 return None
681 if (sl * n_combos) <= best_length:
682 logger(f"Cannot surpass best length {best_length}.")
683 return None
684 min_set_len = sl
686 use_seeds: list[list[int]] = [
687 sorted(st)[:min_set_len] for st in inst_seeds]
688 logger(f"Found {len(use_seeds)} seeds to use.")
689 return [key for key in keys if key[KEY_SEED] in use_seeds[
690 key[KEY_INSTANCE]]]
692 def __str__(self) -> str:
693 """
694 Get the text representation of this selector.
696 :returns "sameNumberOfRuns": always
697 """
698 return "sameNumberOfRuns"
701def __seed_hash(x: tuple[str, int]) -> tuple[str, int]:
702 """
703 Compute a hash for instance-random seeds.
705 The selection algorithm will deterministically choose which runs to
706 keep. To make it truly deterministic, we always sort the settings
707 according to the algorithm, instance, objective function, encoding, and
708 random seed.
710 When we create this order of configurations, we can normally rely on the
711 natural or alphabetic order for algorithms, objectives, instances, and
712 encodings.
713 However, if we also use the natural order for random seeds, this might
714 potentially lead to the problem that runs with larger random seeds are
715 deleted more likely.
716 Deleting some algorithms or instances is not really a problem, but using
717 the seed value to select runs via a natural order of seeds could be
718 problematic.
719 The hash is based on a tuple containing the seed, which is different from
720 the seed but still reliably the same in every run.
721 This should lead to a uniform probability of seed deletion over the random
722 seed spectrum in the absence of other criteria.
724 :param x: the instance-random seeds
725 :return: the hash
726 """
727 return x[0], hash((x[1], )) # force hash to be different from x[1]
730#: All combinations of instances/algorithms/encodings/objectives get the same
731#: number of runs, and on all instances the same seeds are used.
732#: This selector is quite fast and simple.
733#: It will only delete runs, but attempt to retain all instance, algorithms,
734#: objective, and encoding combinations.
735#: It will thus fail if there are some combinations with mutually distinct
736#: run sets.
737SELECTOR_SAME_RUNS_FOR_ALL: Final[tuple[Selector, ...]] = (
738 __SameNumberOfRuns(), )
740#: A simple selector set for maximum runs.
741#: This selector first attempts to do the selection given in
742#: :const:`SELECTOR_SAME_RUNS_FOR_ALL` and then attempts to find
743#: a larger set of consistent runs that might result from dropping off
744#: instances, encodings, algorithms, or objectives.
745SELECTOR_MAX_RUNS_SIMPLE: Final[tuple[Selector, ...]] = (
746 *SELECTOR_SAME_RUNS_FOR_ALL, __HeuristicSelectorTiered())
748#: Select the maximum number of runs in the most thoroughly possible way.
749#: This selector performs many more different attempts compared to
750#: :const:`SELECTOR_MAX_RUNS_SIMPLE`. It is very thorough in attempting to
751#: find the largest possible set of consistent runs. It will therefore also
752#: be much slower.
753SELECTOR_MAX_RUNS: Final[tuple[Selector, ...]] = (
754 *SELECTOR_MAX_RUNS_SIMPLE,
755 __HeuristicSelectorNormTieredRev(),
756 __HeuristicSelectorSum(), __HeuristicSelectorProduct(),
757 __HeuristicSelectorMinimum(), __HeuristicSelectorNormTiered(),
758 __HeuristicSelectorNormTieredRev(), __HeuristicSelectorNormSum())
761def select_consistent(
762 data: Iterable[T],
763 selectors: Iterable[Selector] = SELECTOR_MAX_RUNS) -> list[T]:
764 """
765 Select data such that the numbers of runs are consistent.
767 The input is a set of data items which represent some records over the
768 runs of algorithms on instances. It may be that not all algorithms have
769 been applied to all instances. Maybe the number of runs is inconsistent
770 over the algorithm-instance combinations, too. Maybe some algorithms
771 have more runs on some instances. Maybe the runs are even different,
772 it could be that some algorithms have runs for seed `A`, `B`, and `C` on
773 instance `I`, while others have runs for seed `C` and `D`. This function
774 is designed to retain only the runs with seed `C` in such a case. It may
775 discard algorithms or instances or algorithm-instance-seed combinations
776 in order to obtain a selection of data where all algorithms have been
777 applied to all instances as same as often and using the same seeds.
779 Now there are different ways to select such consistent subsets of a
780 dataset. Of course, we want to select the data such that as much as
781 possible of the data is retained and as little as possible is discarded.
782 This may be a hard optimization problem in itself. Here, we offer a
783 heuristic solution. Basically, we step-by-step try to cut away the
784 setups that are covered by the least amount of runs. We keep repeating
785 this until we arrive in a situation where all setups have the same
786 amount of runs. We then check if there were some strange symmetries that
787 still make the data inconsistent and, if we found some, try to delete
788 one run to break the symmetries and then repeat the cleaning-up process.
789 In the end, we should get a list of overall consistent data elements that
790 can be used during a normal experiment evaluation procedure.
792 This iterative process may be rather slow on larger datasets, but it is
793 maybe the best approximation we can offer to retain as much data as
794 possible.
796 :param data: the source data
797 :param selectors: the selectors to use
798 :return: a list with the selected data
800 >>> def __p(x) -> str:
801 ... return (f"{x.algorithm}/{x.instance}/{x.objective}/{x.encoding}/"
802 ... f"{x.rand_seed}")
804 >>> a1i1o1e1s1 = PerRunData("a1", "i1", "o1", "e1", 1)
805 >>> a1i1o1e1s2 = PerRunData("a1", "i1", "o1", "e1", 2)
806 >>> a1i1o1e1s3 = PerRunData("a1", "i1", "o1", "e1", 3)
807 >>> a1i2o1e1s1 = PerRunData("a1", "i2", "o1", "e1", 1)
808 >>> a1i2o1e1s2 = PerRunData("a1", "i2", "o1", "e1", 2)
809 >>> a1i2o1e1s3 = PerRunData("a1", "i2", "o1", "e1", 3)
810 >>> a2i1o1e1s1 = PerRunData("a2", "i1", "o1", "e1", 1)
811 >>> a2i1o1e1s2 = PerRunData("a2", "i1", "o1", "e1", 2)
812 >>> a2i1o1e1s3 = PerRunData("a2", "i1", "o1", "e1", 3)
813 >>> a2i2o1e1s1 = PerRunData("a1", "i2", "o1", "e1", 1)
814 >>> a2i2o1e1s2 = PerRunData("a2", "i2", "o1", "e1", 2)
815 >>> a2i2o1e1s3 = PerRunData("a2", "i2", "o1", "e1", 3)
817 >>> list(map(__p, select_consistent((
818 ... a1i1o1e1s1, a1i1o1e1s2, a1i1o1e1s3,
819 ... a1i2o1e1s1, a1i2o1e1s2, a1i2o1e1s3,
820 ... a2i1o1e1s1, a2i1o1e1s2,
821 ... a2i2o1e1s2, a2i2o1e1s3))))
822 ['a1/i1/o1/e1/1', 'a1/i1/o1/e1/2', 'a1/i2/o1/e1/2', 'a1/i2/o1/e1/3',\
823 'a2/i1/o1/e1/1', 'a2/i1/o1/e1/2', 'a2/i2/o1/e1/2', 'a2/i2/o1/e1/3']
825 >>> list(map(__p, select_consistent((
826 ... a1i1o1e1s2, a1i1o1e1s3,
827 ... a1i2o1e1s1, a1i2o1e1s2, a1i2o1e1s3,
828 ... a2i1o1e1s1, a2i1o1e1s2,
829 ... a2i2o1e1s2, a2i2o1e1s3))))
830 ['a1/i1/o1/e1/2', 'a1/i2/o1/e1/3', 'a2/i1/o1/e1/2', 'a2/i2/o1/e1/3']
832 >>> list(map(__p, select_consistent((
833 ... a1i1o1e1s2, a1i1o1e1s3,
834 ... a1i2o1e1s1, a1i2o1e1s2, a1i2o1e1s3,
835 ... a2i1o1e1s1, a2i1o1e1s2,
836 ... a2i2o1e1s2))))
837 ['a1/i1/o1/e1/2', 'a1/i2/o1/e1/2', 'a2/i1/o1/e1/2', 'a2/i2/o1/e1/2']
839 >>> list(map(__p, select_consistent((
840 ... a1i1o1e1s1, a1i1o1e1s2, a1i1o1e1s3,
841 ... a2i2o1e1s1, a2i2o1e1s2, a2i2o1e1s3))))
842 ['a1/i1/o1/e1/1', 'a1/i1/o1/e1/2', 'a1/i1/o1/e1/3']
844 >>> list(map(__p, select_consistent((
845 ... a1i1o1e1s1, a1i1o1e1s2, a1i1o1e1s3,
846 ... a2i1o1e1s1, a2i1o1e1s2, a2i1o1e1s3))))
847 ['a1/i1/o1/e1/1', 'a1/i1/o1/e1/2', 'a1/i1/o1/e1/3', \
848'a2/i1/o1/e1/1', 'a2/i1/o1/e1/2', 'a2/i1/o1/e1/3']
850 >>> list(map(__p, select_consistent((
851 ... a1i1o1e1s1, a1i1o1e1s2, a1i2o1e1s2, a1i2o1e1s3))))
852 ['a1/i1/o1/e1/1', 'a1/i1/o1/e1/2', 'a1/i2/o1/e1/2', 'a1/i2/o1/e1/3']
854 >>> list(map(__p, select_consistent((
855 ... a1i1o1e1s1, a1i1o1e1s2, a1i2o1e1s2))))
856 ['a1/i1/o1/e1/1', 'a1/i2/o1/e1/2']
858 >>> list(map(__p, select_consistent((
859 ... a1i1o1e1s1, a2i1o1e1s2))))
860 ['a1/i1/o1/e1/1']
862 >>> list(map(__p, select_consistent((
863 ... a1i1o1e1s1, a2i1o1e1s2, a2i1o1e1s3))))
864 ['a2/i1/o1/e1/2', 'a2/i1/o1/e1/3']
866 >>> try:
867 ... select_consistent((a1i1o1e1s1, a1i1o1e1s2, a1i2o1e1s2, a1i2o1e1s2))
868 ... except ValueError as ve:
869 ... print(str(ve)[:30])
870 Found two records of type PerR
872 >>> try:
873 ... select_consistent(1)
874 ... except TypeError as te:
875 ... print(te)
876 data should be an instance of typing.Iterable but is int, namely 1.
878 >>> try:
879 ... select_consistent({234})
880 ... except TypeError as te:
881 ... print(str(te)[:20])
882 data[0] should be an
884 >>> try:
885 ... select_consistent((a1i1o1e1s1, a1i1o1e1s2, a1i2o1e1s2), 1)
886 ... except TypeError as te:
887 ... print(str(te)[:19])
888 selectors should be
890 >>> try:
891 ... select_consistent((a1i1o1e1s1, a1i1o1e1s2, a1i2o1e1s2), (1, ))
892 ... except TypeError as te:
893 ... print(str(te)[:21])
894 selectors[0] should b
895 """
896 if not isinstance(data, Iterable):
897 raise type_error(data, "data", Iterable)
898 if not isinstance(selectors, Iterable):
899 raise type_error(selectors, "selectors", Iterable)
901 # make data re-iterable
902 use_data = data if isinstance(data, list | tuple) else list(data)
903 total_len: Final[int] = len(use_data)
904 if total_len <= 0:
905 raise ValueError("The data is empty!")
906 algorithm_set: set[str] = set()
907 instance_set: set[str] = set()
908 objective_set: set[str] = set()
909 encoding_set: set[str] = set()
910 seed_set: set[tuple[str, int]] = set()
911 for i, item in enumerate(use_data):
912 if not isinstance(item, PerRunData):
913 raise type_error(item, f"data[{i}]", PerRunData)
914 algorithm_set.add(item.algorithm)
915 instance_set.add(item.instance)
916 objective_set.add(item.objective)
917 encoding_set.add(item.encoding)
918 seed_set.add((item.instance, item.rand_seed))
920 algorithms: Final[list[str]] = sorted(algorithm_set)
921 del algorithm_set
922 instances: Final[list[str]] = sorted(instance_set)
923 del instance_set
924 objectives: Final[list[str]] = sorted(objective_set)
925 del objective_set
926 encodings: Final[list[str | None]] = sorted(
927 encoding_set, key=lambda s: "" if s is None else s)
928 del encoding_set
929 # Random seeds
930 seeds: Final[list[tuple[str, int]]] = sorted(seed_set, key=__seed_hash)
931 del seed_set
932 dims: Final[tuple[int, int, int, int, int]] = (
933 list.__len__(algorithms), list.__len__(instances),
934 list.__len__(objectives), list.__len__(encodings),
935 list.__len__(seeds))
937 logger(f"Found {dims[0]} algorithms, {dims[1]} instances, "
938 f"{dims[2]} objectives, {dims[3]} encodings, and "
939 f"{dims[4]} instance/seed combinations over "
940 f"{total_len} runs in total.")
942 data_map: dict[tuple[int, int, int, int, int], T] = {}
943 for i, item in enumerate(use_data):
944 key = (algorithms.index(item.algorithm),
945 instances.index(item.instance),
946 objectives.index(item.objective),
947 encodings.index(item.encoding),
948 seeds.index((item.instance, item.rand_seed)))
949 if key in data_map:
950 raise ValueError(f"Found two records of type {item},"
951 f"the second one at index {i}.")
952 data_map[key] = item
954 source: tuple[tuple[int, int, int, int, int], ...] = tuple(sorted(
955 data_map.keys()))
956 best_data: list | None = None
957 best_length: int = -1
959 # Apply all the selectors one by one.
960 for i, selector in enumerate(selectors):
961 if not isinstance(selector, Selector):
962 raise type_error(selector, f"selectors[{i}]", Selector)
963 selector_name = str(selector)
964 logger(f"Now invoking selector {selector_name!r}.")
965 best_data_2: list | None = selector.select(
966 dims, source, best_length)
967 if best_data_2 is not None:
968 bdl2 = list.__len__(best_data_2)
969 if bdl2 > best_length:
970 logger(f"Selector {selector_name!r} found new best "
971 f"of {bdl2} runs.")
972 best_length = bdl2
973 best_data = best_data_2
974 if best_length >= total_len:
975 logger("All runs can be retained, we can stop here.")
976 best_data.clear()
977 best_data.extend(use_data)
978 best_data.sort()
979 return best_data
981 del best_data_2
983 if best_length <= 0:
984 raise ValueError("Did not find consistent run subset.")
985 logger(f"After applying all selectors, we got {best_length} records.")
987 # Now we construct the final result.
988 best_data.sort()
989 for i, key in enumerate(best_data):
990 best_data[i] = data_map.pop(key)
991 del data_map
992 best_data.sort()
993 return best_data