desdeo 2.0.0__py3-none-any.whl → 2.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. desdeo/adm/ADMAfsar.py +551 -0
  2. desdeo/adm/ADMChen.py +414 -0
  3. desdeo/adm/BaseADM.py +119 -0
  4. desdeo/adm/__init__.py +11 -0
  5. desdeo/api/__init__.py +6 -6
  6. desdeo/api/app.py +38 -28
  7. desdeo/api/config.py +65 -44
  8. desdeo/api/config.toml +23 -12
  9. desdeo/api/db.py +10 -8
  10. desdeo/api/db_init.py +12 -6
  11. desdeo/api/models/__init__.py +220 -20
  12. desdeo/api/models/archive.py +16 -27
  13. desdeo/api/models/emo.py +128 -0
  14. desdeo/api/models/enautilus.py +69 -0
  15. desdeo/api/models/gdm/gdm_aggregate.py +139 -0
  16. desdeo/api/models/gdm/gdm_base.py +69 -0
  17. desdeo/api/models/gdm/gdm_score_bands.py +114 -0
  18. desdeo/api/models/gdm/gnimbus.py +138 -0
  19. desdeo/api/models/generic.py +104 -0
  20. desdeo/api/models/generic_states.py +401 -0
  21. desdeo/api/models/nimbus.py +158 -0
  22. desdeo/api/models/preference.py +44 -6
  23. desdeo/api/models/problem.py +274 -64
  24. desdeo/api/models/session.py +4 -1
  25. desdeo/api/models/state.py +419 -52
  26. desdeo/api/models/user.py +7 -6
  27. desdeo/api/models/utopia.py +25 -0
  28. desdeo/api/routers/_EMO.backup +309 -0
  29. desdeo/api/routers/_NIMBUS.py +6 -3
  30. desdeo/api/routers/emo.py +497 -0
  31. desdeo/api/routers/enautilus.py +237 -0
  32. desdeo/api/routers/gdm/gdm_aggregate.py +234 -0
  33. desdeo/api/routers/gdm/gdm_base.py +420 -0
  34. desdeo/api/routers/gdm/gdm_score_bands/gdm_score_bands_manager.py +398 -0
  35. desdeo/api/routers/gdm/gdm_score_bands/gdm_score_bands_routers.py +377 -0
  36. desdeo/api/routers/gdm/gnimbus/gnimbus_manager.py +698 -0
  37. desdeo/api/routers/gdm/gnimbus/gnimbus_routers.py +591 -0
  38. desdeo/api/routers/generic.py +233 -0
  39. desdeo/api/routers/nimbus.py +705 -0
  40. desdeo/api/routers/problem.py +201 -4
  41. desdeo/api/routers/reference_point_method.py +20 -44
  42. desdeo/api/routers/session.py +50 -26
  43. desdeo/api/routers/user_authentication.py +180 -26
  44. desdeo/api/routers/utils.py +187 -0
  45. desdeo/api/routers/utopia.py +230 -0
  46. desdeo/api/schema.py +10 -4
  47. desdeo/api/tests/conftest.py +94 -2
  48. desdeo/api/tests/test_enautilus.py +330 -0
  49. desdeo/api/tests/test_models.py +550 -72
  50. desdeo/api/tests/test_routes.py +902 -43
  51. desdeo/api/utils/_database.py +263 -0
  52. desdeo/api/utils/database.py +28 -266
  53. desdeo/api/utils/emo_database.py +40 -0
  54. desdeo/core.py +7 -0
  55. desdeo/emo/__init__.py +154 -24
  56. desdeo/emo/hooks/archivers.py +18 -2
  57. desdeo/emo/methods/EAs.py +128 -5
  58. desdeo/emo/methods/bases.py +9 -56
  59. desdeo/emo/methods/templates.py +111 -0
  60. desdeo/emo/operators/crossover.py +544 -42
  61. desdeo/emo/operators/evaluator.py +10 -14
  62. desdeo/emo/operators/generator.py +127 -24
  63. desdeo/emo/operators/mutation.py +212 -41
  64. desdeo/emo/operators/scalar_selection.py +202 -0
  65. desdeo/emo/operators/selection.py +956 -214
  66. desdeo/emo/operators/termination.py +124 -16
  67. desdeo/emo/options/__init__.py +108 -0
  68. desdeo/emo/options/algorithms.py +435 -0
  69. desdeo/emo/options/crossover.py +164 -0
  70. desdeo/emo/options/generator.py +131 -0
  71. desdeo/emo/options/mutation.py +260 -0
  72. desdeo/emo/options/repair.py +61 -0
  73. desdeo/emo/options/scalar_selection.py +66 -0
  74. desdeo/emo/options/selection.py +127 -0
  75. desdeo/emo/options/templates.py +383 -0
  76. desdeo/emo/options/termination.py +143 -0
  77. desdeo/gdm/__init__.py +22 -0
  78. desdeo/gdm/gdmtools.py +45 -0
  79. desdeo/gdm/score_bands.py +114 -0
  80. desdeo/gdm/voting_rules.py +50 -0
  81. desdeo/mcdm/__init__.py +23 -1
  82. desdeo/mcdm/enautilus.py +338 -0
  83. desdeo/mcdm/gnimbus.py +484 -0
  84. desdeo/mcdm/nautilus_navigator.py +7 -6
  85. desdeo/mcdm/reference_point_method.py +70 -0
  86. desdeo/problem/__init__.py +16 -11
  87. desdeo/problem/evaluator.py +4 -5
  88. desdeo/problem/external/__init__.py +18 -0
  89. desdeo/problem/external/core.py +356 -0
  90. desdeo/problem/external/pymoo_provider.py +266 -0
  91. desdeo/problem/external/runtime.py +44 -0
  92. desdeo/problem/gurobipy_evaluator.py +37 -12
  93. desdeo/problem/infix_parser.py +1 -16
  94. desdeo/problem/json_parser.py +7 -11
  95. desdeo/problem/pyomo_evaluator.py +25 -6
  96. desdeo/problem/schema.py +73 -55
  97. desdeo/problem/simulator_evaluator.py +65 -15
  98. desdeo/problem/testproblems/__init__.py +26 -11
  99. desdeo/problem/testproblems/benchmarks_server.py +120 -0
  100. desdeo/problem/testproblems/cake_problem.py +185 -0
  101. desdeo/problem/testproblems/dmitry_forest_problem_discrete.py +71 -0
  102. desdeo/problem/testproblems/forest_problem.py +77 -69
  103. desdeo/problem/testproblems/multi_valued_constraints.py +119 -0
  104. desdeo/problem/testproblems/{river_pollution_problem.py → river_pollution_problems.py} +28 -22
  105. desdeo/problem/testproblems/single_objective.py +289 -0
  106. desdeo/problem/testproblems/zdt_problem.py +4 -1
  107. desdeo/problem/utils.py +1 -1
  108. desdeo/tools/__init__.py +39 -21
  109. desdeo/tools/desc_gen.py +22 -0
  110. desdeo/tools/generics.py +22 -2
  111. desdeo/tools/group_scalarization.py +3090 -0
  112. desdeo/tools/indicators_binary.py +107 -1
  113. desdeo/tools/indicators_unary.py +3 -16
  114. desdeo/tools/message.py +33 -2
  115. desdeo/tools/non_dominated_sorting.py +4 -3
  116. desdeo/tools/patterns.py +9 -7
  117. desdeo/tools/pyomo_solver_interfaces.py +49 -36
  118. desdeo/tools/reference_vectors.py +118 -351
  119. desdeo/tools/scalarization.py +340 -1413
  120. desdeo/tools/score_bands.py +491 -328
  121. desdeo/tools/utils.py +117 -49
  122. desdeo/tools/visualizations.py +67 -0
  123. desdeo/utopia_stuff/utopia_problem.py +1 -1
  124. desdeo/utopia_stuff/utopia_problem_old.py +1 -1
  125. {desdeo-2.0.0.dist-info → desdeo-2.1.1.dist-info}/METADATA +47 -30
  126. desdeo-2.1.1.dist-info/RECORD +180 -0
  127. {desdeo-2.0.0.dist-info → desdeo-2.1.1.dist-info}/WHEEL +1 -1
  128. desdeo-2.0.0.dist-info/RECORD +0 -120
  129. /desdeo/api/utils/{logger.py → _logger.py} +0 -0
  130. {desdeo-2.0.0.dist-info → desdeo-2.1.1.dist-info/licenses}/LICENSE +0 -0
@@ -16,17 +16,18 @@ from desdeo.tools.message import (
16
16
  Message,
17
17
  MutationMessageTopics,
18
18
  PolarsDataFrameMessage,
19
+ TerminatorMessageTopics,
19
20
  )
20
- from desdeo.tools.patterns import Subscriber
21
+ from desdeo.tools.patterns import Publisher, Subscriber
21
22
 
22
23
 
23
24
  class BaseMutation(Subscriber):
24
25
  """A base class for mutation operators."""
25
26
 
26
27
  @abstractmethod
27
- def __init__(self, problem: Problem, **kwargs):
28
- """Initialize a mu operator."""
29
- super().__init__(**kwargs)
28
+ def __init__(self, problem: Problem, verbosity: int, publisher: Publisher):
29
+ """Initialize a mutation operator."""
30
+ super().__init__(verbosity=verbosity, publisher=publisher)
30
31
  self.problem = problem
31
32
  self.variable_symbols = [var.symbol for var in problem.get_flattened_variables()]
32
33
  self.lower_bounds = [var.lowerbound for var in problem.get_flattened_variables()]
@@ -83,21 +84,23 @@ class BoundedPolynomialMutation(BaseMutation):
83
84
  *,
84
85
  problem: Problem,
85
86
  seed: int,
87
+ verbosity: int,
88
+ publisher: Publisher,
86
89
  mutation_probability: float | None = None,
87
90
  distribution_index: float = 20,
88
- **kwargs,
89
91
  ):
90
92
  """Initialize a bounded polynomial mutation operator.
91
93
 
92
94
  Args:
93
95
  problem (Problem): The problem object.
94
96
  seed (int): The seed for the random number generator.
97
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
98
+ messages are provided at each verbosity level. Recommended value is 1.
99
+ publisher (Publisher): The publisher to which the operator will send messages.
95
100
  mutation_probability (float | None, optional): The probability of mutation. Defaults to None.
96
- distribution_index (float, optional): The distributaion index for polynomial mutation. Defaults to 20.
97
- kwargs: Additional keyword arguments. These are passed to the Subscriber class. At the very least, the
98
- publisher must be passed. See the Subscriber class for more information.
101
+ distribution_index (float, optional): The distribution index for polynomial mutation. Defaults to 20.
99
102
  """
100
- super().__init__(problem, **kwargs)
103
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
101
104
  if self.variable_combination != VariableDomainTypeEnum.continuous:
102
105
  raise ValueError("This mutation operator only works with continuous variables.")
103
106
  if mutation_probability is None:
@@ -125,7 +128,7 @@ class BoundedPolynomialMutation(BaseMutation):
125
128
  # TODO(@light-weaver): Extract to a numba jitted function
126
129
  self.offspring_original = offsprings
127
130
  self.parents = parents # Not used, but kept for consistency
128
- offspring = offsprings.to_numpy()
131
+ offspring = offsprings.to_numpy(writable=True)
129
132
  min_val = np.ones_like(offspring) * self.lower_bounds
130
133
  max_val = np.ones_like(offspring) * self.upper_bounds
131
134
  k = self.rng.random(size=offspring.shape)
@@ -242,8 +245,9 @@ class BinaryFlipMutation(BaseMutation):
242
245
  *,
243
246
  problem: Problem,
244
247
  seed: int,
248
+ verbosity: int,
249
+ publisher: Publisher,
245
250
  mutation_probability: float | None = None,
246
- **kwargs,
247
251
  ):
248
252
  """Initialize a binary flip mutation operator.
249
253
 
@@ -253,10 +257,11 @@ class BinaryFlipMutation(BaseMutation):
253
257
  mutation_probability (float | None, optional): The probability of mutation. If None,
254
258
  the probability will be set to be 1/n, where n is the number of decision variables
255
259
  in the problem. Defaults to None.
256
- kwargs: Additional keyword arguments. These are passed to the Subscriber class. At the very least, the
257
- publisher must be passed. See the Subscriber class for more information.
260
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
261
+ messages are provided at each verbosity level. Recommended value is 1.
262
+ publisher (Publisher): The publisher to which the operator will send messages.
258
263
  """
259
- super().__init__(problem, **kwargs)
264
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
260
265
 
261
266
  if self.variable_combination != VariableDomainTypeEnum.binary:
262
267
  raise ValueError("This mutation operator only works with binary variables.")
@@ -284,7 +289,7 @@ class BinaryFlipMutation(BaseMutation):
284
289
  """
285
290
  self.offspring_original = copy.copy(offsprings)
286
291
  self.parents = parents # Not used, but kept for consistency
287
- offspring = offsprings.to_numpy().astype(dtype=np.bool)
292
+ offspring = offsprings.to_numpy(writable=True).astype(dtype=np.bool)
288
293
 
289
294
  # create a boolean mask based on the mutation probability
290
295
  flip_mask = self.rng.random(offspring.shape) < self.mutation_probability
@@ -373,8 +378,9 @@ class IntegerRandomMutation(BaseMutation):
373
378
  *,
374
379
  problem: Problem,
375
380
  seed: int,
381
+ verbosity: int,
382
+ publisher: Publisher,
376
383
  mutation_probability: float | None = None,
377
- **kwargs,
378
384
  ):
379
385
  """Initialize a random integer mutation operator.
380
386
 
@@ -384,10 +390,11 @@ class IntegerRandomMutation(BaseMutation):
384
390
  mutation_probability (float | None, optional): The probability of mutation. If None,
385
391
  the probability will be set to be 1/n, where n is the number of decision variables
386
392
  in the problem. Defaults to None.
387
- kwargs: Additional keyword arguments. These are passed to the Subscriber class. At the very least, the
388
- publisher must be passed. See the Subscriber class for more information.
393
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
394
+ messages are provided at each verbosity level. Recommended value is 1.
395
+ publisher (Publisher): The publisher to which the operator will send messages.
389
396
  """
390
- super().__init__(problem, **kwargs)
397
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
391
398
 
392
399
  if self.variable_combination != VariableDomainTypeEnum.integer:
393
400
  raise ValueError("This mutation operator only works with integer variables.")
@@ -416,7 +423,7 @@ class IntegerRandomMutation(BaseMutation):
416
423
  self.offspring_original = copy.copy(offsprings)
417
424
  self.parents = parents # Not used, but kept for consistency
418
425
 
419
- population = offsprings.to_numpy().astype(int)
426
+ population = offsprings.to_numpy(writable=True).astype(int)
420
427
 
421
428
  # create a boolean mask based on the mutation probability
422
429
  mutation_mask = self.rng.random(population.shape) < self.mutation_probability
@@ -507,8 +514,9 @@ class MixedIntegerRandomMutation(BaseMutation):
507
514
  *,
508
515
  problem: Problem,
509
516
  seed: int,
517
+ verbosity: int,
518
+ publisher: Publisher,
510
519
  mutation_probability: float | None = None,
511
- **kwargs,
512
520
  ):
513
521
  """Initialize a random mixed_integer mutation operator.
514
522
 
@@ -518,10 +526,11 @@ class MixedIntegerRandomMutation(BaseMutation):
518
526
  mutation_probability (float | None, optional): The probability of mutation. If None,
519
527
  the probability will be set to be 1/n, where n is the number of decision variables
520
528
  in the problem. Defaults to None.
521
- kwargs: Additional keyword arguments. These are passed to the Subscriber class. At the very least, the
522
- publisher must be passed. See the Subscriber class for more information.
529
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
530
+ messages are provided at each verbosity level. Recommended value is 1.
531
+ publisher (Publisher): The publisher to which the operator will send messages.
523
532
  """
524
- super().__init__(problem, **kwargs)
533
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
525
534
 
526
535
  if mutation_probability is None:
527
536
  self.mutation_probability = 1 / len(self.variable_symbols)
@@ -548,7 +557,7 @@ class MixedIntegerRandomMutation(BaseMutation):
548
557
  self.offspring_original = copy.copy(offsprings)
549
558
  self.parents = parents # Not used, but kept for consistency
550
559
 
551
- population = offsprings.to_numpy().astype(float)
560
+ population = offsprings.to_numpy(writable=True).astype(float)
552
561
 
553
562
  # create a boolean mask based on the mutation probability
554
563
  mutation_mask = self.rng.random(population.shape) < self.mutation_probability
@@ -649,9 +658,10 @@ class MPTMutation(BaseMutation):
649
658
  *,
650
659
  problem: Problem,
651
660
  seed: int,
661
+ verbosity: int,
662
+ publisher: Publisher,
652
663
  mutation_probability: float | None = None,
653
664
  mutation_exponent: float = 2.0,
654
- **kwargs,
655
665
  ):
656
666
  """Initialize a small mutation operator.
657
667
 
@@ -660,10 +670,12 @@ class MPTMutation(BaseMutation):
660
670
  seed (int): RNG seed.
661
671
  mutation_probability (float | None): Probability of mutation per gene.
662
672
  mutation_exponent (float): Controls strength of small mutation (larger means smaller mutations).
663
- kwargs: Additional keyword arguments. These are passed to the Subscriber class. At the very least, the
673
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
674
+ messages are provided at each verbosity level. Recommended value is 1.
675
+ publisher (Publisher): The publisher to which the operator will send messages.
664
676
  publisher must be passed. See the Subscriber class for more information.
665
677
  """
666
- super().__init__(problem, **kwargs)
678
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
667
679
  self.rng = np.random.default_rng(seed)
668
680
  self.seed = seed
669
681
  self.mutation_exponent = mutation_exponent
@@ -699,7 +711,7 @@ class MPTMutation(BaseMutation):
699
711
  self.offspring_original = copy.copy(offsprings)
700
712
  self.parents = parents
701
713
 
702
- population = offsprings.to_numpy().astype(float)
714
+ population = offsprings.to_numpy(writable=True).astype(float)
703
715
 
704
716
  for i in range(population.shape[0]):
705
717
  for j, var in enumerate(self.problem.variables):
@@ -757,6 +769,9 @@ class MPTMutation(BaseMutation):
757
769
  ]
758
770
 
759
771
 
772
+ #TODO (@light-weaver): Get rid of the max_generations parameter and instead get it from a message
773
+ # Additionally, allow the operator to use max_evaluations as a basis for decay
774
+ # Make sure that the ratio never exceeds 1.0, otherwise there will be issues
760
775
  class NonUniformMutation(BaseMutation):
761
776
  """Non-uniform mutation operator.
762
777
 
@@ -779,17 +794,18 @@ class NonUniformMutation(BaseMutation):
779
794
  @property
780
795
  def interested_topics(self):
781
796
  """The message topics that the mutation operator is interested in."""
782
- return []
797
+ return [TerminatorMessageTopics.GENERATION]
783
798
 
784
799
  def __init__(
785
800
  self,
786
801
  *,
787
802
  problem: Problem,
788
803
  seed: int,
804
+ max_generations: int,
805
+ verbosity: int,
806
+ publisher: Publisher,
789
807
  mutation_probability: float | None = None,
790
808
  b: float = 5.0, # decay parameter
791
- max_generations: int,
792
- **kwargs,
793
809
  ):
794
810
  """Initialize a Non-uniform mutation operator.
795
811
 
@@ -801,9 +817,11 @@ class NonUniformMutation(BaseMutation):
801
817
  b (float): Non-uniform mutation decay parameter. Higher values cause
802
818
  faster reduction in mutation strength over generations.
803
819
  max_generations (int): Maximum number of generations in the evolutionary run. Used to scale mutation decay.
804
- **kwargs: Additional keyword arguments passed to the base mutation class.
820
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
821
+ messages are provided at each verbosity level. Recommended value is 1.
822
+ publisher (Publisher): The publisher to which the operator will send messages.
805
823
  """
806
- super().__init__(problem, **kwargs)
824
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
807
825
  self.rng = np.random.default_rng(seed)
808
826
  self.seed = seed
809
827
  self.b = b
@@ -858,7 +876,7 @@ class NonUniformMutation(BaseMutation):
858
876
  self.offspring_original = copy.copy(offsprings)
859
877
  self.parents = parents
860
878
 
861
- population = offsprings.to_numpy().astype(float)
879
+ population = offsprings.to_numpy(writable=True).astype(float)
862
880
 
863
881
  for i in range(population.shape[0]):
864
882
  for j, var in enumerate(self.problem.variables):
@@ -875,9 +893,17 @@ class NonUniformMutation(BaseMutation):
875
893
 
876
894
  return self.offspring
877
895
 
878
- def update(self, generation: int, **kwargs):
896
+ def update(self, message: Message):
879
897
  """Update current generation (used to reduce mutation strength over time)."""
880
- self.current_generation = generation
898
+ if not isinstance(message.topic, TerminatorMessageTopics):
899
+ return
900
+ if not isinstance(message.value, int):
901
+ return
902
+ if message.topic != TerminatorMessageTopics.GENERATION:
903
+ raise ValueError(
904
+ f"Expected message topic {TerminatorMessageTopics.GENERATION}, got {message.topic}."
905
+ )
906
+ self.current_generation = message.value
881
907
 
882
908
  def state(self) -> Sequence[Message]:
883
909
  """Return state messages."""
@@ -923,8 +949,9 @@ class SelfAdaptiveGaussianMutation(BaseMutation):
923
949
  *,
924
950
  problem: Problem,
925
951
  seed: int,
952
+ verbosity: int,
953
+ publisher: Publisher,
926
954
  mutation_probability: float | None = None,
927
- **kwargs,
928
955
  ):
929
956
  """Initialize the self-adaptive Gaussian mutation operator.
930
957
 
@@ -933,7 +960,9 @@ class SelfAdaptiveGaussianMutation(BaseMutation):
933
960
  seed (int): Seed for the random number generator to ensure reproducibility.
934
961
  mutation_probability (float | None): Probability of mutating each gene.
935
962
  If None, it defaults to 1 divided by the number of variables.
936
- **kwargs: Additional keyword arguments passed to the base mutation class.
963
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
964
+ messages are provided at each verbosity level. Recommended value is 1.
965
+ publisher (Publisher): The publisher to which the operator will send messages.
937
966
 
938
967
  Attributes:
939
968
  rng (Generator): NumPy random number generator initialized with the given seed.
@@ -943,7 +972,7 @@ class SelfAdaptiveGaussianMutation(BaseMutation):
943
972
  tau_prime (float): Global learning rate, used in step size adaptation.
944
973
  tau (float): Local learning rate, used in step size adaptation.
945
974
  """
946
- super().__init__(problem=problem, **kwargs)
975
+ super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)
947
976
 
948
977
  self.rng = np.random.default_rng(seed)
949
978
  self.seed = seed
@@ -975,7 +1004,7 @@ class SelfAdaptiveGaussianMutation(BaseMutation):
975
1004
  self.offspring_original = offsprings
976
1005
  self.parents = parents
977
1006
 
978
- offspring_array = offsprings.to_numpy().astype(float)
1007
+ offspring_array = offsprings.to_numpy(writable=True).astype(float)
979
1008
 
980
1009
  if step_sizes is None:
981
1010
  step_sizes = np.full_like(offspring_array, fill_value=0.1)
@@ -1051,3 +1080,145 @@ class SelfAdaptiveGaussianMutation(BaseMutation):
1051
1080
  value=self.mutation_probability,
1052
1081
  ),
1053
1082
  ]
1083
+
1084
+
1085
+ class PowerMutation(BaseMutation):
1086
+ """Implements the Power Mutation (PM) operator for real and integer variables."""
1087
+
1088
+ @property
1089
+ def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
1090
+ """The message topics provided by the mutation operator."""
1091
+ return {
1092
+ 0: [],
1093
+ 1: [MutationMessageTopics.MUTATION_PROBABILITY],
1094
+ 2: [
1095
+ MutationMessageTopics.MUTATION_PROBABILITY,
1096
+ MutationMessageTopics.OFFSPRING_ORIGINAL,
1097
+ MutationMessageTopics.OFFSPRINGS,
1098
+ ],
1099
+ }
1100
+
1101
+ @property
1102
+ def interested_topics(self):
1103
+ """The message topics that the mutation operator listens to (none in this case)."""
1104
+ return []
1105
+
1106
+ def __init__(
1107
+ self,
1108
+ *,
1109
+ problem: Problem,
1110
+ seed: int,
1111
+ verbosity: int,
1112
+ publisher: Publisher,
1113
+ p: float = 1.5,
1114
+ mutation_probability: float | None = None,
1115
+ ):
1116
+ """Initialize the PowerMutation operator.
1117
+
1118
+ Args:
1119
+ problem (Problem): The problem definition containing variable bounds and types.
1120
+ seed (int): Random seed for reproducibility.
1121
+ p (float): Power distribution parameter. Controls the perturbation magnitude. Default is 1.5.
1122
+ mutation_probability (float | None): Per-variable mutation probability. Defaults to 1/n.
1123
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
1124
+ messages are provided at each verbosity level. Recommended value is 1.
1125
+ publisher (Publisher): The publisher to which the operator will send messages.
1126
+ """
1127
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
1128
+ self.p = p
1129
+ self.mutation_probability = (
1130
+ mutation_probability if mutation_probability is not None else 1 / len(self.variable_symbols)
1131
+ )
1132
+ self.rng = np.random.default_rng(seed)
1133
+ self.seed = seed
1134
+ self.offspring_original: pl.DataFrame
1135
+ self.parents: pl.DataFrame
1136
+ self.offspring: pl.DataFrame
1137
+
1138
+ def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
1139
+ """Apply Power Mutation to the given offspring population.
1140
+
1141
+ Args:
1142
+ offsprings (pl.DataFrame): The offspring population to mutate.
1143
+ parents (pl.DataFrame): The parent population
1144
+
1145
+ Returns:
1146
+ pl.DataFrame: Mutated offspring population.
1147
+ """
1148
+ self.offspring_original = copy.copy(offsprings)
1149
+ self.parents = parents
1150
+
1151
+ if self.mutation_probability == 0.0:
1152
+ self.offspring = offsprings.clone()
1153
+ self.notify()
1154
+ return self.offspring
1155
+
1156
+ population = offsprings.to_numpy(writable=True).astype(float)
1157
+ mutation_mask = self.rng.random(population.shape) < self.mutation_probability
1158
+ mutated = population.copy()
1159
+
1160
+ for i, var in enumerate(self.problem.variables):
1161
+ lower_bound, upper_bound = var.lowerbound, var.upperbound
1162
+ x_i = population[:, i]
1163
+
1164
+ u_i = self.rng.random(len(x_i)) # uniform random number
1165
+ s_i = u_i ** (1 / self.p) # random number that follows the power distribution
1166
+
1167
+ r_i = self.rng.random(len(x_i)) # another uniform random number
1168
+ direction = ((x_i - lower_bound) / (upper_bound - lower_bound)) < r_i # used as condition
1169
+
1170
+ xi_mutated = np.where(direction, x_i - s_i * (x_i - lower_bound), x_i + s_i * (upper_bound - x_i))
1171
+
1172
+ # Apply mutation based on mask
1173
+ mutated[:, i] = np.where(mutation_mask[:, i], xi_mutated, x_i)
1174
+
1175
+ # Convert back to DataFrame
1176
+ self.offspring = pl.from_numpy(mutated, schema=self.variable_symbols).select(pl.all()).cast(pl.Float64)
1177
+ self.notify()
1178
+
1179
+ return self.offspring
1180
+
1181
+ def update(self, *_, **__):
1182
+ """No update logic needed."""
1183
+
1184
+ def state(self) -> Sequence[Message]:
1185
+ """Return mutation-related state messages based on verbosity level.
1186
+
1187
+ Returns:
1188
+ List of messages reporting mutation probability, input, and output (at higher verbosity).
1189
+ """
1190
+ if self.offspring_original is None or self.offspring is None or self.verbosity == 0:
1191
+ return []
1192
+
1193
+ if self.verbosity == 1:
1194
+ return [
1195
+ FloatMessage(
1196
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
1197
+ source=self.__class__.__name__,
1198
+ value=self.mutation_probability,
1199
+ ),
1200
+ ]
1201
+
1202
+ # Verbosity == 2
1203
+ return [
1204
+ PolarsDataFrameMessage(
1205
+ topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
1206
+ source=self.__class__.__name__,
1207
+ value=self.offspring_original,
1208
+ ),
1209
+ PolarsDataFrameMessage(
1210
+ topic=MutationMessageTopics.PARENTS,
1211
+ source=self.__class__.__name__,
1212
+ value=self.parents,
1213
+ ),
1214
+ PolarsDataFrameMessage(
1215
+ topic=MutationMessageTopics.OFFSPRINGS,
1216
+ source=self.__class__.__name__,
1217
+ value=self.offspring,
1218
+ ),
1219
+ FloatMessage(
1220
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
1221
+ source=self.__class__.__name__,
1222
+ value=self.mutation_probability,
1223
+ ),
1224
+ ]
@@ -0,0 +1,202 @@
1
+ """Classs for scalar selection operators."""
2
+
3
+ from abc import abstractmethod
4
+ from collections.abc import Sequence
5
+
6
+ import numpy as np
7
+ import polars as pl
8
+
9
+ from desdeo.emo.operators.selection import SolutionType
10
+ from desdeo.tools.message import Message, SelectorMessageTopics
11
+ from desdeo.tools.patterns import Publisher, Subscriber
12
+
13
+
14
+ class BaseScalarSelector(Subscriber):
15
+ """A base class for selection operators."""
16
+
17
+ @property
18
+ def provided_topics(self):
19
+ return {
20
+ 0: [],
21
+ 1: [],
22
+ 2: [],
23
+ }
24
+
25
+ @property
26
+ def interested_topics(self):
27
+ return [
28
+ SelectorMessageTopics.SELECTED_FITNESS,
29
+ ]
30
+
31
+ def __init__(self, verbosity: int, publisher: Publisher):
32
+ """Initialize a selection operator."""
33
+ super().__init__(verbosity=verbosity, publisher=publisher)
34
+ self.fitness: np.ndarray | None = None
35
+
36
+ @abstractmethod
37
+ def _do(
38
+ self,
39
+ solutions: tuple[SolutionType, pl.DataFrame],
40
+ fitness: np.ndarray | None = None,
41
+ ) -> tuple[SolutionType, pl.DataFrame]:
42
+ """Perform the selection operation.
43
+
44
+ Args:
45
+ solutions (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
46
+ The second element is the objective values, targets, and constraint violations.
47
+ fitness (np.ndarray | None, optional): The fitness values of the solutions. If None, the fitness is
48
+ calculated from the messages sent by the publisher.
49
+
50
+ Returns:
51
+ SolutionType: The selected decision variables.
52
+ """
53
+
54
+ def do(
55
+ self,
56
+ solutions: tuple[SolutionType, pl.DataFrame],
57
+ fitness: np.ndarray | None = None,
58
+ ) -> tuple[SolutionType, pl.DataFrame]:
59
+ """Perform the selection operation.
60
+
61
+ Args:
62
+ solutions (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
63
+ The second element is the objective values, targets, and constraint violations.
64
+ fitness (np.ndarray | None, optional): The fitness values of the solutions. If None, the fitness is
65
+ calculated from the messages sent by the publisher.
66
+
67
+ Returns:
68
+ SolutionType: The selected decision variables.
69
+ """
70
+ if fitness is not None and self.fitness is not None:
71
+ raise RuntimeError("The fitness is being set twice.")
72
+ if fitness is None and self.fitness is None:
73
+ raise RuntimeError(
74
+ "The fitness is not set. Either pass it as an argument or make sure the publisher sends it."
75
+ )
76
+ if fitness is None:
77
+ fitness = self.fitness
78
+ if len(fitness) != len(solutions[0]):
79
+ raise ValueError(
80
+ f"The length of the fitness array ({len(fitness)}) does not match"
81
+ f" the number of solutions ({len(solutions[0])})."
82
+ )
83
+ returnval = self._do(solutions, fitness)
84
+ self.fitness = None # Reset fitness after selection
85
+ return returnval
86
+
87
+ def update(self, message: Message) -> None:
88
+ """Update the operator with a message.
89
+
90
+ Args:
91
+ message (Message): The message to update the operator with.
92
+ """
93
+ if message.topic == SelectorMessageTopics.SELECTED_FITNESS and isinstance(message.value, np.ndarray):
94
+ self.fitness = message.value
95
+ else:
96
+ raise ValueError(f"Unknown message topic: {message.topic}")
97
+
98
+ def state(self) -> Sequence[Message]:
99
+ return []
100
+
101
+
102
+ class TournamentSelection(BaseScalarSelector):
103
+ """A tournament selection operator."""
104
+
105
+ def __init__(
106
+ self,
107
+ *,
108
+ winner_size: int,
109
+ verbosity: int,
110
+ publisher: Publisher,
111
+ tournament_size: int = 2,
112
+ seed: int | None = None,
113
+ selection_probability: float | None = None,
114
+ ) -> None:
115
+ """Initialize the tournament selection operator.
116
+
117
+ Args:
118
+ winner_size (int): The number of winners to select.
119
+ verbosity (int): The verbosity level of the operator.
120
+ publisher (Publisher): The publisher to send messages to.
121
+ tournament_size (int, optional): The size of the tournament. Defaults to 2, which corresponds to binary
122
+ tournament.
123
+ seed (int | None, optional): The seed for the random number generator. If None, the deterministic tournament
124
+ selection is used, i.e., the solution with the highest fitness in the tournament is always selected.
125
+ Otherwise the selection is stochastic, and the solution is selected with a probability proportional to
126
+ its fitness. Defaults to None.
127
+ selection_probability (float | None, optional): The probability of selecting a solution in the tournament.
128
+ If None, but a seed is provided, then the probabilities are proportional to the fitness values of the
129
+ solutions in the tournament. If None and no seed is provided, then the selection is deterministic.
130
+ If a value is provided, and the seed is not None, then the selection is stochastic, and the
131
+ probabilities of choosing the k-best solution in the tournament is given by p * (1 - p) ** (k - 1),
132
+ where p is the selection probability. Note that doing selection with a probability proportional to
133
+ fitness is equivalent to roulette wheel selection.
134
+ """
135
+ super().__init__(verbosity=verbosity, publisher=publisher)
136
+ self.winner_size = winner_size
137
+ self.tournament_size = tournament_size
138
+ self.seed = seed
139
+ self.rng = np.random.default_rng(seed)
140
+ self.selection_probability = selection_probability
141
+ if self.seed is None and self.selection_probability is not None:
142
+ raise ValueError(
143
+ "If selection_probability is provided, seed must also be provided to ensure stochastic selection."
144
+ )
145
+
146
+ @staticmethod
147
+ def deterministic_select(indices: np.ndarray, fitness: np.ndarray) -> int:
148
+ """Select the index of the solution with the highest fitness from the given indices.
149
+
150
+ Args:
151
+ indices (np.ndarray): The indices of the solutions to select from.
152
+ fitness (np.ndarray): The fitness values of the solutions.
153
+
154
+ Returns:
155
+ int: The index of the solution with the highest fitness.
156
+ """
157
+ return indices[np.argmax(fitness)]
158
+
159
+ def stochastic_select(self, indices: np.ndarray, fitness: np.ndarray) -> int:
160
+ """Select the index of the solution with a probability proportional to its fitness from the given indices.
161
+
162
+ Args:
163
+ indices (np.ndarray): The indices of the solutions to select from.
164
+ fitness (np.ndarray): The fitness values of the solutions.
165
+
166
+ Returns:
167
+ int: The index of the selected solution.
168
+ """
169
+ if self.selection_probability is None:
170
+ probabilities = fitness / np.sum(fitness)
171
+ probabilities = np.cumsum(probabilities)
172
+ else:
173
+ indices = indices[np.argsort(fitness)[::-1]] # Sort indices by fitness in descending order
174
+ probabilities = np.array(
175
+ [self.selection_probability * (1 - self.selection_probability) ** i for i in range(len(indices))]
176
+ )
177
+ random_value = self.rng.random()
178
+ selected_index = np.searchsorted(probabilities, random_value)
179
+ return indices[selected_index]
180
+
181
+ def _do(
182
+ self, solutions: tuple[SolutionType, pl.DataFrame], fitness: np.ndarray | None = None
183
+ ) -> tuple[SolutionType, pl.DataFrame]:
184
+ """Perform the tournament selection operation.
185
+
186
+ Args:
187
+ solutions (tuple[SolutionType, pl.DataFrame]): The decision variables and their outputs.
188
+ fitness (np.ndarray | None, optional): The fitness values of the solutions.
189
+
190
+ Returns:
191
+ tuple[SolutionType, pl.DataFrame]: The selected decision variables and their outputs.
192
+ """
193
+ selected_indices = np.zeros(self.winner_size, dtype=int)
194
+ for i in range(self.winner_size):
195
+ tournament_indices = self.rng.choice(range(len(solutions[0])), size=self.tournament_size, replace=True)
196
+ if self.seed is None:
197
+ selected_indices[i] = self.deterministic_select(tournament_indices, fitness[tournament_indices])
198
+ else:
199
+ selected_indices[i] = self.stochastic_select(tournament_indices, fitness[tournament_indices])
200
+ selected_solutions = solutions[0][selected_indices]
201
+ selected_outputs = solutions[1][selected_indices]
202
+ return selected_solutions, selected_outputs